REST API overview

JSON over HTTPS. Bearer-token auth. Designed so a CRM workflow can place a call when a lead arrives, then receive the outcome via webhook.

Base URL

https://app.cualify.in/api/v1

All 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 /calls
  • contacts.read — GET /contacts
  • contacts.write — POST /contacts
  • webhooks.read — GET /webhooks
  • webhooks.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_id isn't in your org; 422 unprocessable if the agent isn't active
  • 422 unprocessable with details.reason: outside_calling_window (TRAI allows 09:00–21:00 IST — retry inside it), blackout_date, or dnd_registry (the number is on the NCPR — don't retry)
  • 402 insufficient_credits — top up at /billing
  • 429 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_limited with a Retry-After header (seconds until the window resets), also echoed as details.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?