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/.
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 beactive.to_e164— Indian number in E.164:+91followed 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); otherwise422. 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 successfullycall.failed— call dropped / rejected by carriercall.no_answer— recipient didn't pick upcontact.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 shape401 invalid_key/missing_authorization402 insufficient_credits403 key_revoked/forbidden(missing scope, workspace not approved, usage limit, expired plan)404 not_found422 unprocessable— valid request that can't go ahead (TRAI calling window, DND, inactive agent, caller ID you don't own)429 rate_limited— with aRetry-Afterheader500 internal_error(withrequestIdfor 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.