Skip to main content
Polling GET /v1/calls to notice a call ended works, and it is wasteful. A webhook tells you instead: when something happens on your account we POST a signed JSON body to a URL you own. There is one endpoint per account, not a collection — so the API is GET /v1/webhook and PATCH /v1/webhook, with no id anywhere.

Set it up

Then mint a signing secret. It is returned once:
Rotating replaces the secret immediately. A receiver that verifies signatures will reject every event until it has been redeployed with the new value, so rotate deliberately — not as part of a routine config change.
GET /v1/webhook never returns the secret, to anyone. It returns secret_set instead. An endpoint that hands a signing secret back would turn any leaked read-scoped key into the ability to forge signed events.

Fields

A URL pointing at loopback, link-local or private address space is refused with 400 unsafe_webhook_url, and refused again at delivery time. This server can reach addresses you cannot, so it will not be aimed at them on request.

Events

The ids are camelCase. That is deliberate and it is the one place in this API where they are: these are not field names, they are the literal value of the event key in the JSON we POST, and they have been since long before /v1. Your handler’s switch and your configuration use the same strings. An account that has never configured webhooks is subscribed to all of them. Every event fires the same way whether the action came from the API, an SDK, an MCP client or the console — the same record, the same event. Two deliberate exceptions:
  • POST /v1/leads/bulk fires nothing. A thousand-row import is one action by the person doing it; turning it into a thousand POSTs at your endpoint is a denial of service dressed as a feature. Import, then read the response.
  • POST /v1/agents/{id}/test-call fires outboundCall with "test": true in the payload. It rings a real phone and spends real credit, so it is a real outbound call — but a receiver that does not want to act on probes can tell them apart on that field.

The payload

Sent with these headers:

Verifying the signature

Compute the HMAC over the raw bytes of the request body, before any JSON parsing. Re-serialising the parsed object produces different bytes and a different digest.
Compare with a constant-time function. A plain === on a hex digest leaks the correct value one byte at a time to anyone who can measure the difference.

Answer quickly

We wait 8 seconds for a response. Anything outside 2xx is a failure, including a 3xx — we do not follow redirects, because a redirect is how a POST gets replayed somewhere it was never vetted for. Do the work after you answer. A handler that finishes a database write before returning 200 turns your own slowness into a retry.

Retries

A failed delivery is retried twice, about 5 seconds and 30 seconds later, on failures that can plausibly succeed next time:
  • no response at all — DNS failure, refused connection, timeout;
  • 408, 429, or any 5xx.
Every other 4xx is you telling us the request is wrong, and sending it twice more does not make it right.
Retries are held in the API process’s memory. A deploy or a restart between attempts drops the pending one — the attempts already made are still recorded, but the event is not re-queued. Treat webhooks as best-effort notification and reconcile against GET /v1/calls if you need a guarantee.

Did it fire?

That question used to be answerable only by us, reading server logs. Now:
One record per attempt, newest first — so an event delivered on its third try is three records, and a receiver that flaps looks like one. The field to read first is response_status. null means nothing answered at all: wrong hostname, closed port, or a timeout. A number means your endpoint was reached and what it said. That distinction is the whole difference between “my URL is wrong” and “my handler threw”.
error never contains your response body. A receiver that echoes the request back would otherwise put customer names and phone numbers into a log, through the side door. Only the status line and transport errors are kept, and records age out after three months.

Test before you enable

It POSTs one sample payload now and hands back what your endpoint answered, in the same shape as a delivery record. It goes through the same code as a real event — same headers, same signature, and it appears in the delivery list — but it deliberately ignores enabled and your event subscription, because the question it answers is whether your endpoint works before you turn delivery on. The payload carries obviously fake data and "test": true.

Scopes

webhooks:read for the configuration and the delivery list; webhooks:write to change the URL, rotate the secret or send a test.
webhooks:write can repoint where every event on the account is delivered. Treat it like a credential that can redirect your data, because it is one.