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
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 theevent 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/bulkfires 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-callfiresoutboundCallwith"test": truein 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
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.=== 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 any5xx.
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: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
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.

