Docs · API · Webhooks

Webhooks.

Get pushed the moment a call starts, finishes, or fails instead of polling for it. Deliveries follow the Standard Webhooks spec — an open signing scheme adopted across major API platforms — so existing verifier libraries work unchanged.

Register an endpoint#

POST/v1/webhooks
FieldTypeDescription
urlstring (url)requiredWhere events are POSTed. Must be https in production.
eventsstring[]optionalWhich events to receive — call.started, call.completed, call.failed. Omitted, the endpoint subscribes to every event type.
Request
curl -X POST https://voice.whizztech.ai/v1/webhooks \
  -H "Authorization: Bearer $WHIZZ_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.your-app.com/hooks/whizz",
    "events": ["call.started", "call.completed", "call.failed"]
  }'
Response · 201 Created
{
  "id": "a4f81c2d-6e93-40b7-95d2-3c08e7b1f649",
  "object": "webhook_endpoint",
  "url": "https://api.your-app.com/hooks/whizz",
  "events": ["call.started", "call.completed", "call.failed"],
  "secret": "whsec_Zks3JprXcO1FaK9yTqR2v8wBnE5dLmHu"
}
GET/v1/webhooks
Response · 200 OK
{
  "object": "list",
  "data": [
    {
      "id": "a4f81c2d-6e93-40b7-95d2-3c08e7b1f649",
      "url": "https://api.your-app.com/hooks/whizz",
      "events": ["call.started", "call.completed", "call.failed"],
      "active": true,
      "created_at": "2026-07-10T10:02:31.000Z"
    }
  ]
}

Endpoints are managed (deactivated, inspected, deliveries reviewed) at Dashboard → Webhooks. Inactive endpoints receive nothing.

Event catalog#

FieldTypeDescription
call.startedeventA call connected and the agent is live. data is the call object with status "in_progress" — recording, transcript, and outcome are still null.
call.completedeventA call ended normally. data carries the full call object — recording_url, transcript.segments, tool_calls, outcome, sentiment, and cost. This is the receipt.
call.failedeventA call never completed — status is "failed", "no_answer", or "busy". data.error explains, and no voice minutes are billed.

The body is { event, data }, where data is the full call object. A completed call arrives with everything attached — recording, transcript segments, and the tool log:

Delivery · call.completed
POST /hooks/whizz HTTP/1.1
Content-Type: application/json
webhook-id: 7c2f4e91-0b5d-4a68-93e1-d8f60a2c47b5
webhook-timestamp: 1783677103
webhook-signature: v1,K6mQxNvB2rTz8wYpL0dHc4jFgS7aEuXiOn9kM3sRq1U=

{
  "event": "call.completed",
  "data": {
    "id": "call_1d84c0e795b2",
    "object": "call",
    "agent_id": "ag_7Q2c51b84a07",
    "direction": "outbound",
    "status": "completed",
    "from": "+966920000123",
    "to": "+966500000001",
    "phone_number_id": "pn_5b8f0d21",
    "channel": "phone",
    "duration_sec": 96,
    "billed_sec": 96,
    "language": "ar",
    "dialect": "saudi",
    "lang_tier": "arabic",
    "outcome": "resolved",
    "sentiment": "positive",
    "recording_url": "https://voice.whizztech.ai/rec/call_1d84c0e795b2.mp3?sig=…",
    "transcript": {
      "segments": [
        { "speaker": "agent", "start": 0.0, "end": 3.1, "text": "حياك الله، معك مساعد وِز. كيف أقدر أخدمك؟" },
        { "speaker": "caller", "start": 3.4, "end": 6.8, "text": "أبغى أعرف حالة طلبي A-2291." },
        { "speaker": "agent", "start": 7.0, "end": 11.2, "text": "طلبك خرج للتوصيل ويوصلك اليوم قبل المغرب." }
      ],
      "language_code": "ar-SA",
      "word_count": 148
    },
    "tool_calls": [
      { "name": "lookup_order", "args": { "order": "A-2291" }, "result": { "status": "out_for_delivery", "eta": "2026-07-13" } }
    ],
    "cost": { "voice_credits": 24, "telephony_usd": 0.024 },
    "error": null,
    "created_at": "2026-07-13T09:32:18.000Z",
    "ended_at": "2026-07-13T09:33:54.000Z"
  }
}
Body · call.failed
{
  "event": "call.failed",
  "data": {
    "id": "call_7b2e0a9c6543",
    "object": "call",
    "agent_id": "ag_7Q2c51b84a07",
    "direction": "outbound",
    "status": "no_answer",
    "from": "+966920000123",
    "to": "+966500000002",
    "phone_number_id": "pn_5b8f0d21",
    "channel": "phone",
    "duration_sec": 0,
    "billed_sec": 0,
    "outcome": null,
    "sentiment": null,
    "recording_url": null,
    "transcript": null,
    "tool_calls": [],
    "cost": { "voice_credits": 0, "telephony_usd": 0.006 },
    "error": { "code": "no_answer", "message": "Recipient did not answer before ring timeout." },
    "created_at": "2026-07-13T09:34:02.000Z",
    "ended_at": "2026-07-13T09:34:32.000Z"
  }
}

Delivery semantics#

  • Events go to every active endpoint subscribed to that event type.
  • Your endpoint has 10 seconds to respond. Any 2xx counts as delivered; anything else — or a timeout — records the delivery as failed with the status code and error.
  • Three attempts per event. A failed delivery retries after ~30 seconds, then again after ~5 minutes; after the third failure it is marked dead. A retried delivery keeps its webhook-id but re-signs with a fresh webhook-timestamp. Still treat polling as the source of truth: on any gap, re-fetch GET /v1/calls/{id}. Delivery history is visible in the dashboard.
  • Ack fast: return 200 immediately and process the event on a queue. Slow handlers are the top cause of missed deliveries.
  • Deliveries can arrive out of order relative to your own API reads — always key your logic off data.id and data.status, not arrival order. Use webhook-id to dedupe retries.

Signature verification#

Every delivery carries three headers:

FieldTypeDescription
webhook-idstringUnique delivery ID. Also your idempotency key for deduping.
webhook-timestampstringUnix seconds when the delivery was signed.
webhook-signaturestringv1,<base64 MAC> — HMAC-SHA256 over the signed content.

The scheme, exactly:

  1. Key: strip the whsec_ prefix from your secret and base64-decode the remainder. The decoded bytes are the HMAC key — do not use the secret string directly.
  2. Signed content: the string {webhook-id}.{webhook-timestamp}.{raw body} — the three values joined with periods, using the raw request body exactly as received.
  3. Signature: "v1," + base64(HMAC-SHA256(key, signed content)). Compare against the header in constant time, and reject timestamps outside a small tolerance window (5 minutes is standard) to block replays.

Node#

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

/**
 * Verify a Whizz Voice webhook (Standard Webhooks scheme).
 * payload must be the RAW request body string — not re-serialized JSON.
 */
export function verifyWhizzWebhook(payload, headers, secret, toleranceSec = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;

  // reject stale or future-dated deliveries (replay protection)
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;

  // key = base64-decoded secret without the whsec_ prefix
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signedContent = id + "." + timestamp + "." + payload;
  const expected =
    "v1," + createHmac("sha256", key).update(signedContent).digest("base64");

  // the header may carry multiple space-delimited signatures; ours sends one
  return signature.split(" ").some((candidate) => {
    const a = Buffer.from(candidate);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

// Express example — mount with express.raw() so the body stays untouched:
// app.post("/hooks/whizz", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyWhizzWebhook(req.body.toString("utf8"), req.headers, process.env.WHIZZ_WEBHOOK_SECRET)) {
//     return res.status(401).end();
//   }
//   const { event, data } = JSON.parse(req.body.toString("utf8"));
//   res.status(200).end(); // ack fast, process async
// });

Python#

verify.py
import base64
import hashlib
import hmac
import time


def verify_whizz_webhook(payload: bytes, headers: dict, secret: str, tolerance_sec: int = 300) -> bool:
    """payload must be the RAW request body bytes — not re-serialized JSON."""
    wid = headers.get("webhook-id")
    ts = headers.get("webhook-timestamp")
    sig = headers.get("webhook-signature")
    if not (wid and ts and sig):
        return False

    # reject stale or future-dated deliveries (replay protection)
    if abs(time.time() - int(ts)) > tolerance_sec:
        return False

    # key = base64-decoded secret without the whsec_ prefix
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{wid}.{ts}.".encode() + payload
    digest = hmac.new(key, signed_content, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(digest).decode()

    # the header may carry multiple space-delimited signatures; ours sends one
    return any(hmac.compare_digest(candidate, expected) for candidate in sig.split(" "))