cf
CallFunnel.ai

Docs · API

REST API reference.

Every endpoint, every request/response, every auth requirement. Base URL https://callfunnel.ai/api (or wherever your tenant is hosted).

Your first request, in 60 seconds

If you've used Twilio or Anthropic before, this will feel identical. Three steps:

  1. Open /api-keys in your dashboard and mint a key. Copy the full cf_live_… secret — it's shown once.
  2. Pick any endpoint below. Send Authorization: Bearer cf_live_… on every call.
  3. Read the response. Errors are JSON with a detail field; HTTP status codes follow REST conventions.

Here's the smallest working call (reads your workspace's wallet balance — safe to run anytime):

cURL

curl https://callfunnel.ai/api/billing/balance \ -H "Authorization: Bearer cf_live_YOUR_KEY_HERE"

Python (requests)

import requests r = requests.get( "https://callfunnel.ai/api/billing/balance", headers={"Authorization": "Bearer cf_live_YOUR_KEY_HERE"}, ) r.raise_for_status() print(r.json())

Node (fetch)

const r = await fetch( "https://callfunnel.ai/api/billing/balance", { headers: { "Authorization": "Bearer cf_live_YOUR_KEY_HERE" } } ); if (!r.ok) throw new Error(`HTTP ${r.status}`); console.log(await r.json());

Expected response

{ "tenant_id": 38, "currency": "USD", "balance_minor": 500, "plan_tier": "free", "trial_expires_at": "2026-06-01T07:30:45Z", "minutes_remaining_at_bundled_rate": 41, "rate_per_minute_minor": 12, "autocharge": { "enabled": false, "threshold_minor": null, "amount_minor": null }, "topup_presets_minor": [1000, 2500, 5000, 10000, 25000] }

Explore interactively

The API ships with auto-generated interactive docs. Useful when you want to try a call without leaving the browser, or when you want to import the schema into Postman/Insomnia.

Authentication

Three ways to authenticate. Server-to-server integrations should use API keys; browser apps use the session cookie.

  • API keyAuthorization: Bearer cf_live_…. Created in your dashboard; identifies the workspace, not an individual user. Recommended for all programmatic access.
  • Cookiecf_session, HttpOnly + Secure (set by POST /auth/login). Browser apps use this.
  • JWT bearerAuthorization: Bearer <jwt>. Returned in the login response body. Use this for short-lived per-user sessions in non-browser clients.

JWT TTL: 7 days. Algorithm: HS256. Claims include sub, tid (tenant), role, email, iat, exp.

API keys carry one of three scopes:

  • read — GET-only endpoints (balance, history, list resources, status).
  • write — read + state-changing POST/PUT (top-ups, autocharge, provision numbers, credential updates).
  • admin — write + destructive actions (release a number, delete provider credentials, DSR erase).

Keys are shown once when created and stored as a sha256 lookup on our side — we cannot recover a lost key. Optional expiry (days/months/never), revocable any time, audit-trailed.

Endpoints marked JWT below also accept an API key with the appropriate scope; endpoints marked JWT only require a logged-in user (account self-management) and reject API keys by design.

Auth

POST/auth/signupPUBLIC

Create a new tenant + owner user. Sends verify-email best-effort. Auto-credits trial ($5 / ₹450).

Request: { "email", "password" (min 10), "full_name"?, "country"? (IN|US|GB|AU|CA|SG|AE) } Response 201: { "tenant": {id, name, status, plan_tier, created_at}, "user": {id, tenant_id, email, role, email_verified, ...}, "verify_email_sent": true }
GET/auth/verify-email?token=…PUBLIC

Idempotent. Token TTL 24h.

Response 200: { "ok": true, "email", "verified_at" }
POST/auth/loginPUBLIC

Sets cf_session cookie + returns bearer token.

Request: { "email", "password" } Response: { "token", "expires_in" (sec), "user", "tenant" } Errors: 401 invalid creds · 403 inactive · 400 OAuth-only account
POST/auth/logoutPUBLIC

Clears the session cookie.

GET/auth/meJWT

Current user + tenant.

Response: { "user", "tenant", "expires_at" }

API keys

Manage programmatic keys for your workspace. These routes require a logged-in user (cookie or JWT) — an API key cannot manage other API keys.

GET/api-keysJWT only

List every key in the workspace (active, expired, revoked). Secret values are never returned.

Response 200: { "keys": [{ "id", "name", "prefix" (e.g. "cf_live_aB3xYz"), "scope": "read"|"write"|"admin", "status": "active"|"expired"|"revoked", "created_at", "created_by_user_id", "expires_at" (nullable), "last_used_at", "last_used_ip" (nullable), "revoked_at", "revoked_reason" (nullable) }, ...] }
POST/api-keysJWT only

Mint a new key. The full secret is returned in this response and only this response — copy it before navigating away. Maximum 10 active keys per workspace.

Request: { "name": "ci robot", // required, ≤120 chars "scope": "read" | "write" | "admin", // default "admin" // pick one (or neither = never expires): "expires_in_days": 30, "expires_at": "2026-12-31T00:00:00Z" } Response 201: { "id", "name", "prefix", "scope", "status": "active", "secret": "cf_live_aB3xYz…", // ← shown ONCE, never returned again "created_at", "expires_at", ... } Errors: 400 (bad scope/name/cap reached) · 401 (no JWT) · 403 (user not in tenant)
POST/api-keys/{id}/revokeJWT only

Flip revoked_at on the key. Subsequent calls using it get 401 immediately. Idempotent — revoking an already-revoked key is a no-op.

Request: { "reason"?: "string ≤80 chars" } Response: { ...row, "status": "revoked", "revoked_at", "revoked_reason" }
DELETE/api-keys/{id}JWT only

Hard-delete a key row (removes audit trail). Only allowed after revoke — call /revoke first or you get 409.

Response 200: { "deleted": } Errors: 409 (key still active, revoke first)

Workflows

A workflow is one campaign — a rule-book (NL prompt + Opus-generated script), an audience CSV, and a schedule. Lifecycle: draftlivepausedretired. Workflows are the core resource you'll create with the API.

GET/api/workflowsJWT · read

List your workspace's workflows. Filter by status with ?status=draft|live|paused|retired.

Response 200: { "tenant_id", "workflows": [{ "id", "name", "status", "persona", "schedule_kind", "rule_book_id", "updated_at", "created_at" }, ...] }
GET/api/workflows/{id}JWT · read

Full workflow detail including the rule book.

Response 200: { "id", "name", "status", "persona", "schedule_kind", "rule_book": { "system_prompt", "objection_handlers", ... }, "audience_count", "runs_count", "compliance": {...}, "created_at", "updated_at" }
POST/api/workflowsJWT · write

Create a workflow from a natural-language prompt. We send the prompt to Claude Opus, validate the generated rule book, and persist it. Failures return 422 with the validator errors so you can edit + retry.

Request: { "nl_prompt": "renewal-recovery for SaaS, IST 10am-7pm, …", // ≤4000 chars "persona_override"?: "amelia" | "rohan" | "kai" | "anya" | "leo" | "priya", "is_test"?: true, "author_user_id"?: "string" } Response 201: { "id", "name", "status": "draft", "persona", "rule_book": {…} } Errors: 422 (validator rejected the generated rule book)
PUT/api/workflows/{id}JWT · write

Update name, persona, schedule, or rule-book fields. Returns 409 if the workflow is currently live — pause it first to edit.

POST/api/workflows/{id}/audienceJWT · write

Attach an audience by uploading a CSV (multipart) or posting a JSON list. Phone numbers are normalised to E.164 and filtered against your DNC list + NCPR (India) + 7-day cool-down.

Request (multipart): file=@audience.csv Request (JSON): { "rows": [{ "phone": "+91...", "name": "...", ... }, ...] } Response 200: { "accepted", "rejected_dnc", "rejected_ncpr", "rejected_cooldown", "rejected_invalid", "total_targets" }
POST/api/workflows/{id}/runJWT · write

Start a run — kicks off the dialer for this workflow. Pre-call billing precheck applies (insufficient funds → 402).

Response 201: { "run_id", "status": "running", "targets_queued" } Errors: 402 (wallet insufficient) · 409 (workflow not ready / not live)
POST/api/workflows/{id}/pauseJWT · write

Pause the dialer. In-flight calls finish; queued targets stop. Resumable by calling /run again.

POST/api/workflows/{id}/retireJWT · admin

Terminal state — workflow can't be un-retired. Refuses if any run is currently running (pause first).

POST/api/workflows/{id}/regenerate-rule-bookJWT · write

Re-run the Opus generator against the workflow's nl_prompt (or a new prompt in the body). Useful when you've edited the prompt or you want a fresh generation. Returns 422 if the regenerated rule book fails validation.

Request: { "nl_prompt"?: "updated prompt — optional" }
DELETE/api/workflows/{id}JWT · admin

Soft-delete — sets status=retired + archived_at. Refuses if any run is currently running.

GET/api/workflows/{id}/runsJWT · read

Last N runs for this workflow, newest first.

Response: { "runs": [{ "id", "status", "started_at", "ended_at", "targets_total", "targets_dialed", "targets_connected", "approvals_pending", "concessions_decided", ... }, ...] }
POST/api/workflows/preview-openingPUBLIC

Stateless TTS preview — send a script line + persona, get back an MP3 URL. Useful to hear an opener before creating the workflow.

Request: { "text", "persona": "amelia|rohan|..." } Response: { "mp3_url", "duration_ms" }

Approvals

When the AI proposes something material — a discount, a refund waiver, a callback handoff — it routes to your Slack (or appears here) for a human decision. The approvals queue + decide endpoint are the API equivalent.

GET/api/approvalsJWT · read

Pending approvals queue for your workspace. Filter by ?workflow_id= or ?status=pending|approved|denied.

Response: { "approvals": [{ "id", "workflow_id", "run_id", "target_phone", "proposed_action": "concession" | "callback" | "escalate", "proposed_value_minor", "currency", "rationale", "context", "status", "created_at", "expires_at" }, ...] }
POST/api/approvals/{id}/decideJWT · write

Approve or deny a pending decision. Same endpoint Slack interactivity hits — record the outcome and resume the AI's conversation.

Request: { "decision": "approve" | "deny", "note"?: "string ≤500 chars" } Response: { "id", "status", "decided_at", "decided_by" } Errors: 404 (not in your workspace) · 409 (already decided / expired)

Customer timeline

GET/api/timelineJWT · read

Cross-channel narrative for one contact — calls, WA messages, email touches, decisions. Identify the contact by phone, email, or user_id. Limit defaults to 50, max 500.

Query: ?phone=+91... OR ?user_id=... OR ?email=... &n=50&before=2026-05-01T00:00:00Z Response: { "count", "events": [{ "at", "kind": "call" | "wa_message" | "email" | "decision" | ..., "direction": "inbound" | "outbound", "workflow_id", "run_id", "summary", "payload": {...} }, ...] } Errors: 400 (no identifier given)

Compliance & DNC

Your tenant's do-not-call list. Audience uploads automatically filter against this; you can also pre-check a number before dialling manually.

GET/dncJWT · read

List DNC entries. Paginated with ?limit=100&offset=0.

POST/dnc/addJWT · write

Add a number to DNC. Idempotent — re-adding refreshes the timestamp + reason.

Request: { "phone_e164": "+91...", "reason"?: "string ≤200" } Response: { "entry": { "id", "phone_e164", "reason", "added_at" } }
DELETE/dnc/{phone_e164}JWT · write

Remove a number from DNC. URL-encode the leading +.

GET/dnc/checkJWT · read

Test a number against DNC + NCPR (India) + 7-day cool-down without committing a call.

Query: ?phone_e164=+91... Response: { "phone_e164", "on_dnc", "on_ncpr", "in_cooldown", "would_be_filtered" }
POST/dnc/importJWT · write

Bulk-import a DNC list. Accepts a CSV upload (multipart) or a JSON array.

Request (JSON): { "rows": [{ "phone_e164", "reason"? }, ...] } Response: { "added", "skipped_existing", "skipped_invalid" }
PUT/api/workflows/{id}/complianceJWT only

Attest consent for a workflow (TCPA / DPDP / GDPR). The attestation records the logged-in user as consent_attested_by_user_id — that's why API keys are rejected here; the audit trail needs a human identity.

Request: { "consent_attestation_text": "string", "tcpa_sender_id"?: "string", "dpdp_consent_method"?: "string" }

Reports & shared library

GET/api/reports/weeklyJWT · read

PDF digest for a date range — workflow KPIs, approval volume, top objections, cost breakdown.

Query: ?from=2026-05-18&to=2026-05-24 Response: application/pdf binary stream
GET/api/objectionsPUBLIC

Global shared library of seeded objections + suggested rebuttals. Read-only catalog, not tenant-scoped — useful when authoring custom rule books outside the wizard.

Response: { "objections": [{ "id", "category", "trigger_text", "rebuttal_template", "use_count" }, ...] }

Onboarding

GET/onboarding/providersPUBLIC

Catalogue of supported providers + field schemas (Anthropic, Twilio, Exotel, Cartesia, Deepgram).

GET/onboarding/statusJWT

Current onboarding mode, validation state per provider, completion checklist.

PUT/onboarding/modeJWT

Switch between bundled and byok. Saved keys stay; just go dormant.

Request: { "mode": "bundled" | "byok" }
PUT/onboarding/credentials/{provider}JWT

Save + live-validate a provider credential. AES-256-GCM encrypted at rest. Returns 503 if MEK is not set.

Request: { "primary_id"?, "secret", "extra"?: {…} } Response: { "saved": true, "validated": true|false, "error"?, "credential": {…} }
DELETE/onboarding/credentials/{provider}JWT

Remove a credential. Tenant onboarding flips back to incomplete.

POST/onboarding/completeJWT

Mark onboarding done. 409 if not ready (lists missing fields).

Billing

GET/billing/balanceJWT

Wallet balance, per-minute rate, minutes remaining, autocharge config.

GET/billing/precheck?expected_seconds=180JWT

Pre-call gate. Returns ok / insufficient_funds / trial_expired / account_suspended.

GET/billing/history?limit=50JWT

Immutable wallet ledger. Max 200 entries per call.

POST/billing/topupJWT

Create a top-up checkout intent. Wallet credits on webhook confirmation, not on intent creation.

Request: { "amount_minor" (1 .. 10_000_000), "return_url"? } Response: { "intent_id", "amount_minor", "currency", "status": "pending", "external_id", "checkout_url" }
POST/billing/autochargeJWT

Configure auto-recharge.

Request: { "enabled": true|false, "threshold_minor"?, "amount_minor"? }
POST/billing/webhooks/paymentSIGNATURE

Payment-processor webhook. HMAC-SHA256 signature verified, idempotent on event id.

Numbers

GET/numbersJWT

List tenant numbers (provisioned + imported).

POST/numbers/searchJWT

Search available inventory.

Request: { "country" (ISO 2-4), "area_code"?, "limit" (1-50) }
POST/numbers/provisionJWT

Buy a number. Webhooks (voice inbound + status callback) auto-configured.

POST/numbers/importJWT

Import an existing number (you must own it on the provider side).

POST/numbers/{id}/releaseJWT

Release a number. Webhooks unwired. Monthly recurring stops.

Health & public

GET/healthzPUBLIC

Service health + provider config status.

GET/voicesPUBLIC

Available personas (6 EN + 1 HI) with sample MP3 URLs.

DPDP / DSR

POST/dsr/exportJWT

Export everything for a contact. Returns under 30s.

Request: { "contact_identifier" (phone or email) } Response: { "bundle": {...}, "recordings_zip_url"? }
POST/dsr/eraseJWT

Erase everything for a contact. Audit-keep rows pseudonymised.

Error format

All errors return JSON:

{ "error": "human-readable message", "code": "machine-readable token (e.g. INVALID_CREDENTIALS)", "field"?: "if a single field is at fault" }

HTTP statuses follow the obvious mapping: 400 bad request, 401 auth required, 403 forbidden, 404 not found, 409 conflict, 422 validation, 429 rate-limited, 5xx server.

Rate limits

Per-tenant rate limits are not enforced yet in the public beta. Be reasonable — sustained > 10 req/sec from a single workspace will eventually trip an automated alarm. When we activate enforcement we'll publish concrete per-endpoint thresholds and emit X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers on 429 responses. If you have a known burst pattern that needs higher headroom, email hello@callfunnel.ai ahead of time.

Versioning

The API is unversioned today (pre-1.0). Breaking changes are announced 60 days ahead via a banner in the dashboard and an email to admin users. Post-launch we'll add a /v1/ prefix; current callers stay on the unprefixed path through a sunset window.