The shape in one snippet
POST https://your-app.com/cualify-webhook
content-type: application/json
x-cualify-event: call.completed
x-cualify-delivery: 8f4e21c0-... (unique per attempt — use as dedup key)
x-cualify-timestamp: 1748704931842 (ms since epoch)
x-cualify-signature: t=1748704931842,v1=8a2c3... (hex hmac-sha256)
{
"id": "evt_...",
"event": "call.completed",
"created_at": "2026-05-31T14:22:11.842Z",
"data": {
"call_id": "e7b1d6c5-...",
"provider_call_id": "boln_...",
"status": "completed",
"duration_sec": 142,
"recording_url": "https://..."
}
}Supported events
call.completed— call finished naturally. Includesduration_sec+recording_urlwhen available.call.failed— call ended in a failure state (carrier rejection, provider error, timeout).call.no_answer— recipient didn't pick up before the timeout.contact.created— a new contact was upserted into your contact book viaPOST /api/v1/contactsor a CSV import.
More coming (agent.published, campaign.finished, balance.low). Subscribe to one or more at registration via POST /api/v1/webhooks; see REST API overview.
Signature verification
Every delivery carries an x-cualify-signature header in the form t=<timestamp>,v1=<hex>. To verify:
- Extract
tandv1from the header. - Reject if
|now - t| > 5 minutes(replay-attack guard). - Recompute the HMAC over the literal payload
<timestamp>.<raw-body>using SHA-256 and the secret you saved when registering the webhook (see Node example below for the exact expression). - Constant-time compare against
v1. Reject on mismatch.
Node.js example
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.CUALIFY_WEBHOOK_SECRET!; // saved on registration
const TOLERANCE_MS = 5 * 60 * 1000;
export function verifyCualifyWebhook(rawBody: string, headers: Record<string, string>): boolean {
const header = headers["x-cualify-signature"];
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const t = parts.t, v1 = parts.v1;
if (!t || !v1) return false;
// Replay guard
if (Math.abs(Date.now() - Number(t)) > TOLERANCE_MS) return false;
// Recompute
const expected = createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Important: verify against the raw request body. Don't JSON.parse first — re-stringifying introduces whitespace differences that break the signature.
Python example
import hmac, hashlib, time
SECRET = os.environ["CUALIFY_WEBHOOK_SECRET"].encode()
TOLERANCE_MS = 5 * 60 * 1000
def verify(raw_body: bytes, headers: dict) -> bool:
header = headers.get("x-cualify-signature", "")
parts = dict(p.split("=") for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1: return False
if abs(int(time.time() * 1000) - int(t)) > TOLERANCE_MS: return False
expected = hmac.new(SECRET, f"{t}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Payload schemas per event
call.completed
{
"id": "evt_...",
"event": "call.completed",
"created_at": "...",
"data": {
"call_id": "uuid",
"provider_call_id": "boln_...",
"status": "completed",
"duration_sec": 142,
"recording_url": "https://..." | null
}
}call.failed / call.no_answer
{
"id": "evt_...",
"event": "call.failed" | "call.no_answer",
"created_at": "...",
"data": {
"call_id": "uuid",
"provider_call_id": "boln_...",
"status": "failed" | "no_answer",
"reason": "string | null"
}
}contact.created
{
"id": "evt_...",
"event": "contact.created",
"created_at": "...",
"data": {
"contact_id": "uuid",
"phone_e164": "+919812345678",
"name": "string | null",
"consent_capture": "pre_call | website_form | manual | imported"
}
}Delivery semantics
- At-least-once. Use
x-cualify-deliveryas your idempotency key — duplicates are possible (network blip, retry). - Timeout. We wait 10 seconds for your response. Slower than that = treated as failed.
- Success criteria. Any 2xx HTTP status. Non-2xx counts as failure.
- Retries (current behaviour). Phase H2: one synchronous attempt, logged either way. Phase H3 (queued): exponential backoff over 24 hours (1m, 5m, 15m, 1h, 4h, 12h) before giving up.
- Parallel deliveries. If you have multiple endpoints subscribed to the same event, they fire in parallel — slow on one doesn't delay the others.
Delivery logs
Every delivery (success or fail) lands in /integrations → Webhooks → click your endpoint. The log shows the last 100 attempts with HTTP status, response-body excerpt (first 500 chars), and timestamp. Useful for catching a quietly-failing endpoint before it costs you 1,000 missed events.
Local development
Tunnel a local handler with ngrok or Cloudflare Tunnel — Cualify needs HTTPS. We refuse to register http:// URLs and won't deliver to localhost.
# ngrok
ngrok http 3000
# then register the public URL
curl -X POST https://app.cualify.in/api/v1/webhooks \
-H "Authorization: Bearer cuk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/cualify-webhook",
"events": ["call.completed", "call.failed"]
}'Common gotchas
- Verifying parsed JSON instead of raw body. Re-serialising changes whitespace; the HMAC fails. Always verify on the raw bytes.
- Saving the masked secret instead of the real one. The secret is returned once on POST /api/v1/webhooks. If you missed it, delete and re-register.
- Replaying an old event in dev. Our 5-minute replay-guard rejects anything outside that window. Re-trigger a fresh event from the dashboard instead.