Cualify.
OutboundInboundBookingCollectionsPricingSecurityBlog
Sign inStart a pilot

Cualify Field Notes

Building India's AI calling stack — in public.

One short essay every other Friday on voice AI, Indian SMB GTM, and what we ship. No spam. Unsubscribe in one click.

Cualify.

AI calling agency for Indian SMBs. Multilingual voice agents, INR billing, DPDP-ready from day one.

Built in Mumbai · Stored in Mumbai

Product

  • Outbound sales
  • Inbound support
  • Appointment booking
  • Collections
  • Voice library
  • Docs

Company

  • Pricing
  • Cualify vs Bolna
  • Changelog
  • Security
  • Blog
  • About
  • Contact

Legal

  • Terms of service
  • Privacy policy
  • Data Processing
  • Acceptable Use
  • 15-day refund

Compliance

  • DPDP Act 2023
  • TRAI / DLT
  • 99.5% SLA
  • Sub-processors
  • Customer KYC
  • Contact DPO

© 2026 Cualify Technologies. All rights reserved.

[email protected]·[email protected]·+91 80 0000 0000

DocsIntegrations

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?

  • REST API overview
  • Zoho + HubSpot setup

Got a question this page didn't answer? Ping us on WhatsApp or email support. We typically reply in under 2 hours.