Docs · API · Webhooks
Webhooks.
Get pushed the moment a call finishes analyzing 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| Field | Type | Description | |
|---|---|---|---|
| url | string (url) | required | Where events are POSTed. Must be https in production. |
| events | string[] | optional | Which events to receive. For call analysis, subscribe to job.succeeded and job.failed. Omitted, the endpoint subscribes to every event type. |
curl -X POST https://pulse.whizztech.ai/v1/webhooks \
-H "Authorization: Bearer $PULSE_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.your-app.com/hooks/pulse",
"events": ["job.succeeded", "job.failed"]
}'{
"id": "a4f81c2d-6e93-40b7-95d2-3c08e7b1f649",
"object": "webhook_endpoint",
"url": "https://api.your-app.com/hooks/pulse",
"events": ["job.succeeded", "job.failed"],
"secret": "whsec_Zks3JprXcO1FaK9yTqR2v8wBnE5dLmHu"
}GET
/v1/webhooks{
"object": "list",
"data": [
{
"id": "a4f81c2d-6e93-40b7-95d2-3c08e7b1f649",
"url": "https://api.your-app.com/hooks/pulse",
"events": ["job.succeeded", "job.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#
| Field | Type | Description |
|---|---|---|
| job.succeeded | event | An analyze_call job finished — the call's transcript, analysis, and QA scorecard are ready. data.result carries the headline numbers; fetch GET /v1/calls/{callId} for the full result. |
| job.failed | event | An analysis failed. data.error explains; the call's status is "failed" and the credit was refunded before this event fired. |
The body is { event, data }, where data is the job payload. Note that the keys inside data.result are camelCase (callId, qaScore) — they mirror the job's stored result verbatim:
POST /hooks/pulse HTTP/1.1
Content-Type: application/json
webhook-id: 7c2f4e91-0b5d-4a68-93e1-d8f60a2c47b5
webhook-timestamp: 1783677103
webhook-signature: v1,K6mQxNvB2rTz8wYpL0dHc4jFgS7aEuXiOn9kM3sRq1U=
{
"event": "job.succeeded",
"data": {
"id": "5b8f0d21-6a3e-4c97-b1d0-84e7f2a9c655",
"type": "analyze_call",
"status": "succeeded",
"result": {
"callId": "9f2c51b8-4a07-4e63-b1d8-72e0a5c93f14",
"qaScore": 71.5,
"verdict": "partial",
"sentiment": "negative"
},
"error": null,
"credits_charged": 1,
"created_at": "2026-07-11T09:32:18.000Z",
"finished_at": "2026-07-11T09:36:02.000Z"
}
}{
"event": "job.failed",
"data": {
"id": "5b8f0d21-6a3e-4c97-b1d0-84e7f2a9c655",
"type": "analyze_call",
"status": "failed",
"result": null,
"error": "Error: transcription failed: unsupported codec",
"credits_charged": 1,
"created_at": "2026-07-11T09:32:18.000Z",
"finished_at": "2026-07-11T09:33:40.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 itswebhook-idbut re-signs with a freshwebhook-timestamp. Still treat polling as the source of truth: on any gap, re-fetchGET /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.idanddata.status, not arrival order. Usewebhook-idto dedupe retries.
Signature verification#
Every delivery carries three headers:
| Field | Type | Description |
|---|---|---|
| webhook-id | string | Unique delivery ID. Also your idempotency key for deduping. |
| webhook-timestamp | string | Unix seconds when the delivery was signed. |
| webhook-signature | string | v1,<base64 MAC> — HMAC-SHA256 over the signed content. |
The scheme, exactly:
- 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. - Signed content: the string
{webhook-id}.{webhook-timestamp}.{raw body}— the three values joined with periods, using the raw request body exactly as received. - 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#
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Verify a Whizz Pulse webhook (Standard Webhooks scheme).
* payload must be the RAW request body string — not re-serialized JSON.
*/
export function verifyPulseWebhook(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/pulse", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyPulseWebhook(req.body.toString("utf8"), req.headers, process.env.PULSE_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#
import base64
import hashlib
import hmac
import time
def verify_pulse_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(" "))