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, campaign.completed, job.succeeded, job.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.
campaign.completedeventEvery call in an outbound campaign has landed. data carries the campaign id and the total / completed / failed counts.
job.succeededeventA background analysis job finished. data is the job object, including its output.
job.failedeventA background analysis job failed. data.error explains, and the credits it charged are refunded.

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(" "))