Webhook events + payloads

HMAC-SHA256-signed JSON deliveries to your HTTPS endpoint. Stripe-style signature scheme. Sub-second from the underlying call event.

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. Includes duration_sec + recording_url when 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 via POST /api/v1/contacts or 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:

  1. Extract t and v1 from the header.
  2. Reject if |now - t| > 5 minutes (replay-attack guard).
  3. 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).
  4. 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-delivery as 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.

What's next?