Cualify · REST API v1

Place calls from your CRM

JSON-over-HTTPS REST. Same endpoints power our own dashboard, so anything you can do in the UI you can do from your backend. Webhooks push status updates back so you don't poll.

Get an API key

Sign in to Cualify, then go to Settings → API keys and click Generate key. You'll see the full key once — copy it into your secrets manager (1Password, AWS Secrets Manager, your.env) before dismissing the dialog.

Keys look like cuk_live_a1b2c3d4xxxxxxxxxxxxxxxxxxxxxxxx. Treat them like passwords — anyone with the plaintext can act as your org within the key's scopes.

Pick scopes when you generate the key (all are ticked by default) and give each integration only what it needs: calls.read, calls.write, contacts.read, contacts.write, webhooks.read, webhooks.write. Each endpoint below names the scope it needs. Keys generated before scopes existed keep full access.

Authentication

Send your key in the Authorization header on every request:

Authorization: Bearer cuk_live_<your-key>

Bad key → 401 invalid_key. Revoked key → 403 key_revoked. Key without the endpoint's scope → 403 forbidden. Same JSON envelope as every other error response, see Errors below.

Place an outbound call

POST/api/v1/calls

Needs calls.write. Queues a call to to_e164 with the agent you specify and returns 202 Accepted with a queued call id; status updates flow via webhooks (see below, each carries the id as call_id) or you can poll GET /api/v1/calls/{id}.

curl -X POST https://app.cualify.in/api/v1/calls \
  -H "Authorization: Bearer $CUALIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "00000000-0000-0000-0000-000000000000",
    "to_e164": "+919812345678"
  }'

Required body fields:

  • agent_id — UUID from /playbooks. Must be active.
  • to_e164 — Indian number in E.164: +91 followed by 10 digits, e.g. +919812345678. International, toll-free and premium-rate numbers are refused.

Optional:

  • from_e164 — caller ID. Needs a connected Exotel account and must be one of your org's caller IDs (the one configured on your Exotel connection, the ExoPhone we detected, or a number Cualify assigned to you); otherwise 422. Omit it to use your default.
  • language — e.g. hi-IN, en-IN.
  • max_duration_sec — cap (10–900s). Can shorten the agent's own limit, never extend it.

Compliance and account checks run before the call is queued, and again at dial time: outside 09:00–21:00 IST or a DND/NCPR number → 422 unprocessable with details.reason (outside_calling_window, blackout_date, dnd_registry); workspace not approved, usage limit reached or plan expired → 403 forbidden; low wallet → 402 insufficient_credits. If a check fails after queueing, nothing is dialed and the call moves to failed — visible via GET, but no call.failed webhook is sent for a call that was never dialed.

List calls

GET/api/v1/calls?status=completed&limit=50

Needs calls.read. Paginated by limit (max 100) and offset. Filter by status to pull only completed calls.

Get a call

GET/api/v1/calls/{id}

Needs calls.read. Full detail incl. recording_url, transcript, and llm_extracted_intent once post-call analysis (an LLM pass over the transcript after the call ends) has run.

Sync a contact (upsert)

POST/api/v1/contacts

Needs contacts.write (listing with GET needs contacts.read). Creates a contact if no row exists for (your-org, e164); otherwise patches the named fields. Use this on every CRM lead-create / lead-update event.

curl -X POST https://app.cualify.in/api/v1/contacts \
  -H "Authorization: Bearer $CUALIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "e164": "+919812345678",
    "first_name": "Aditya",
    "language_pref": "hi-IN",
    "source": "hubspot"
  }'

Subscribe to webhooks

POST/api/v1/webhooks

Needs webhooks.write (as does DELETE; listing needs webhooks.read). Register an HTTPS endpoint and the events you want pushed to it. We sign every delivery with HMAC-SHA256 so you can verify the request actually came from us.

curl -X POST https://app.cualify.in/api/v1/webhooks \
  -H "Authorization: Bearer $CUALIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-crm.example.com/cualify/webhook",
    "events": ["call.completed", "call.failed"]
  }'

The response includes secret (a 64-char hex string) shown once. Store it — you'll need it to verify signatures.

Supported events (Phase 1):

  • call.completed — call hung up successfully
  • call.failed — call dropped / rejected by carrier
  • call.no_answer — recipient didn't pick up
  • contact.created — new contact appeared in your org

Verify a webhook signature

Each delivery includes these headers:

X-Cualify-Event: call.completed
X-Cualify-Delivery: 8b1c5fd0-...
X-Cualify-Timestamp: 1762812345000
X-Cualify-Signature: t=1762812345000,v1=<hex>

Reject the request if the signature doesn't match HMAC-SHA256(secret, "${timestamp}.${raw_body}"):

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(req, secretHex) {
  const sig = req.headers["x-cualify-signature"]; // "t=...,v1=..."
  const ts = req.headers["x-cualify-timestamp"];
  const v1 = sig.match(/v1=([0-9a-f]+)/)?.[1];
  if (!v1 || !ts) return false;

  // Reject events older than 5 minutes — replay-attack defense.
  if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false;

  const expected = createHmac("sha256", Buffer.from(secretHex, "hex"))
    .update(`${ts}.${req.rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Errors

Every error response uses the same envelope:

{
  "error": {
    "code": "invalid_request",
    "message": "Body validation failed.",
    "param": "to_e164",
    "details": { ... }
  }
}

Common codes:

  • 400 invalid_request — bad input shape
  • 401 invalid_key / missing_authorization
  • 402 insufficient_credits
  • 403 key_revoked / forbidden (missing scope, workspace not approved, usage limit, expired plan)
  • 404 not_found
  • 422 unprocessable — valid request that can't go ahead (TRAI calling window, DND, inactive agent, caller ID you don't own)
  • 429 rate_limited — with a Retry-After header
  • 500 internal_error (with requestId for support)
  • 502 upstream_error — a provider we depend on (Exotel, Razorpay, Sarvam) returned a bad response

Rate limits & quotas

POST /api/v1/calls is limited to 30 requests / minute per org, across all of the org's keys (fixed one-minute windows; refused requests count too). Over the limit you get 429 rate_limited with a Retry-After header in seconds. For bulk dialing, use a campaign — it paces itself. Separately, an org can hold at most 20 active keys. Need higher? Email support@cualify.in.

Need help?

support@cualify.in — first reply within minutes during business hours (9 AM–9 PM IST). Include the requestId from any 500 response and we can trace it instantly.