Base URL
https://app.cualify.in/api/v1All endpoints are namespaced under /api/v1. We'll version-bump to v2 only on breaking changes; additive changes (new fields, new endpoints) ship without a version bump.
Authentication
Every request needs a Bearer token in the Authorization header. Generate a key in Settings → API keys; you get a full key starting with cuk_live_. Save it immediately — we never echo the secret again, only the masked prefix.
Authorization: Bearer cuk_live_abc12345_<your-32-char-secret>Keys are scoped to a single org. Revoke any time from the same page — the next request with that key returns 403 key_revoked.
Scopes
Each key carries scopes, picked when you generate it (all are ticked by default). Every endpoint needs one; a key without it gets 403 forbidden naming the missing scope. Read never implies write.
calls.read—GET /calls,GET /calls/{id}calls.write—POST /callscontacts.read—GET /contactscontacts.write—POST /contactswebhooks.read—GET /webhookswebhooks.write—POST /webhooks,DELETE /webhooks/{id}
Keys generated before scopes existed keep full access. To narrow one, generate a new key with only the scopes it needs and revoke the old one.
Error envelope
Every non-2xx response uses the same JSON shape:
{
"error": {
"code": "invalid_request",
"message": "Body validation failed.",
"param": "to_e164",
"details": { /* zod-flat for validation failures */ }
}
}Codes are stable strings (invalid_request, not_found, insufficient_credits, forbidden, unprocessable, rate_limited, upstream_error, internal_error). Switch on code in your client, not on the HTTP status — status can change between minor versions; the code won't.
Endpoints
POST /api/v1/calls — place an outbound call
Needs calls.write. The call is queued, not dialed inline, so the response is 202 Accepted with the call's id.
POST /api/v1/calls
Authorization: Bearer cuk_live_...
Content-Type: application/json
{
"agent_id": "0a8c5a4e-...", // agent uuid from /playbooks — must be active
"to_e164": "+919812345678", // recipient — Indian numbers only (+91 + 10 digits)
"from_e164": "+918012345678", // optional — must be a number your org owns
"language": "hi-IN", // optional — defaults to the agent's language
"max_duration_sec": 300 // optional — can shorten the agent's limit, not extend it
}
→ 202 Accepted
{
"data": {
"id": "e7b1d6c5-...",
"status": "queued",
"agent_id": "0a8c5a4e-...",
"to_e164": "+919812345678",
"from_e164": null, // null = your default caller ID, picked at dial time
"language": "hi-IN",
"created_at": "2026-05-31T14:22:11Z"
}
}Store the returned id against your CRM record — every webhook delivery for the call carries it as call_id. The status starts as queued and ends as completed, no_answer, busy or failed. To track: subscribe to call.completed / call.failed webhooks (recommended), or poll GET /api/v1/calls/{id}.
Who you can call. to_e164 must be an Indian number in E.164 (+91 followed by 10 digits). International numbers, and Indian toll-free, shared-cost and premium-rate numbers (1800 / 1860 / 1900), are refused with 400 invalid_request.
Caller ID. Omit from_e164 and the call goes out from your configured ExoPhone (or our pool if no carrier is connected). Setting it needs a connected Exotel account, and the number must be one of your org's caller IDs — the one configured on your Exotel connection, the ExoPhone we detected on your Exotel account, or a number Cualify assigned to you. Anything else fails with 422 unprocessable on from_e164.
Before anything is queued we run the same checks as every call placed from the dashboard. Each failure returns immediately:
403 forbidden— workspace not yet approved, usage limit reached, or plan expired (the message says which)404 not_found—agent_idisn't in your org;422 unprocessableif the agent isn't active422 unprocessablewithdetails.reason:outside_calling_window(TRAI allows 09:00–21:00 IST — retry inside it),blackout_date, ordnd_registry(the number is on the NCPR — don't retry)402 insufficient_credits— top up at /billing429 rate_limited— see Rate limits
The same checks run again at the moment of dialing. If one fails in between — say the calling window closes while the call is queued — nothing is dialed and the call moves to failed. A call that was never dialed doesn't send a call.failed webhook today, so if you rely on webhooks, also check GET /api/v1/calls/{id} for calls you haven't heard back about.
GET /api/v1/calls — list calls
GET /api/v1/calls?limit=50&offset=0&status=completed
Authorization: Bearer cuk_live_...
→ 200 OK
{
"data": [
{
"id": "...",
"status": "completed",
"direction": "outbound",
"from_e164": "+918012345678",
"to_e164": "+919812345678",
"language": "hi-IN",
"duration_sec": 142,
"credits_debited_paise": 1420,
"recording_url": "https://...",
"llm_extracted_intent": "demo_booked",
"llm_sentiment": "positive",
"llm_outcome": "demo_scheduled_2026_06_03",
"started_at": "...",
"ended_at": "...",
"created_at": "..."
}
],
"pagination": { "limit": 50, "offset": 0, "total": 1247, "has_more": true }
}Query params: limit (1–100, default 50), offset (default 0), status (one of the 9 status values). Ordered newest-first.
GET /api/v1/calls/{id} — full call detail
Same fields as the list endpoint plus the full transcript (when status=completed) and per-event timeline (disclosure-played, intent- detected, handoff-requested, etc.).
POST /api/v1/contacts — upsert a contact
Use this from your CRM to keep Cualify's contact book in sync. Idempotent on phone_e164 — repeat calls update the existing row.
POST /api/v1/contacts
Authorization: Bearer cuk_live_...
Content-Type: application/json
{
"phone_e164": "+919812345678",
"name": "Riya Sharma",
"email": "riya@acme.in", // optional
"language_pref": "hi-IN", // optional
"metadata": { "lead_source": "google_ads" },
"consent_capture": "website_form" // pre_call | website_form | manual | imported
}
→ 200 OK
{
"data": { "id": "...", "phone_e164": "+919812345678", "name": "Riya Sharma", "updated_at": "..." }
}GET /api/v1/contacts — list contacts
Paginated. Same limit / offset shape as calls. Filter by consent_capture if you want only the contacts you can legally call.
POST /api/v1/webhooks — register an outbound webhook
Subscribe a HTTPS endpoint to receive event deliveries. Cualify signs every payload with HMAC-SHA256 so you can verify authenticity. Full details: Webhook events + payloads.
POST /api/v1/webhooks
Authorization: Bearer cuk_live_...
Content-Type: application/json
{
"url": "https://your-app.com/cualify-webhook",
"events": ["call.completed", "call.failed"]
}
→ 201 Created
{
"data": { "id": "...", "url": "...", "events": ["call.completed", "call.failed"], "active": true, "created_at": "..." },
"secret": "abcdef0123...64chars" // returned ONCE — store it now
}GET /api/v1/webhooks — list registered endpoints
Returns each registration with its last delivery timestamp + last HTTP status. Useful for monitoring delivery health.
DELETE /api/v1/webhooks/{id} — remove a webhook
Immediate. No 30-day soft-delete — once you DELETE, no further events ship to that URL.
Rate limits
POST /calls: 30 requests / minute / org, counted across all of your org's keys in fixed one-minute windows. Refused requests count too. For bulk dialing, use a campaign — it paces itself.- Over the limit:
429 rate_limitedwith aRetry-Afterheader (seconds until the window resets), also echoed asdetails.retry_after_sec. - Higher limits available on Scale / Enterprise — email support@cualify.in.
cURL quickstart
# Place a call
curl -X POST https://app.cualify.in/api/v1/calls \
-H "Authorization: Bearer cuk_live_..." \
-H "Content-Type: application/json" \
-d '{
"agent_id": "0a8c5a4e-...",
"to_e164": "+919812345678"
}'
# Poll until completed
curl https://app.cualify.in/api/v1/calls/<id> \
-H "Authorization: Bearer cuk_live_..."SDKs
We don't ship an official SDK yet — the API surface is small enough that customers wire it directly. If you build a Node / Python / Ruby wrapper and want it listed here, send a PR or email support@cualify.in.
What's next?
- Webhook events + payloads — sign verification + retry policy