Webhooks
A completed call posts one consolidated event to your endpoint — status, transcript, recording, cost and analysis in a single body. How to subscribe, which events exist, and what your endpoint must do to receive them.
Polling GET /v2/calls/{execution_id} in a loop is the wrong way to learn that a
call finished — see Rate Limits. Point us at an HTTPS
endpoint instead and we will post the finished call to it.
Every call produces one complete event, and several smaller ones on the way
there. The complete one is all_processing_completed, fired after every
piece of post-call processing has landed and carrying all 47 fields at once —
it is the event to build on. Ahead of it come the progress events
(call_ringing, call_started, call_completed, and the recording and
analysis events), each carrying the 20-field envelope only.
A Custom Webhook set in the dashboard receives all of them. A subscription
created with POST /v2/webhooks receives exactly the event types you name —
use it when you want fewer.
Two ways to receive events
Both deliver the same payload, with the same signing, the same retry schedule and the same delivery log. Pick on how you want it configured, not on what you get.
Custom Webhook — in the dashboard
Open the agent, go to Integrations & Tools → Custom Webhook, paste an
HTTPS URL. No API call, no key, nothing to deploy. Receives
all_processing_completed for that agent. This is how most integrations
are set up, and it is the right answer if one finished-call event per agent
is what you need.
Subscriptions — POST /v2/webhooks
Create it over the API. Adds the other seven event types, an organization-wide scope, your own signing secret, custom headers, and programmatic management. Reach for it when you need progress events, one endpoint for every agent, or webhooks configured by code rather than by hand.
They do not stack. A subscription for an agent replaces that agent's dashboard Custom Webhook completely — see Which subscription receives a call. That is what guarantees you never receive the same call twice.
There is no per-call webhook URL
POST /v2/calls takes no webhook_url. The destination is configured on the
agent or on a subscription — never per request — which is what gives every
event signing, automatic retries, and a delivery log you can inspect at
GET /v2/webhooks/deliveries.
A per-call URL had none of those, and it pointed the call's own result callback somewhere
else — which is how a call ends up connected, billed by the carrier, and never recorded on
our side. Sending webhook_url in a call body is rejected with 1001 rather than ignored,
so an older integration finds out on its first request instead of silently losing events.
For the same reason POST /v2/calls takes no provider. Which carrier places a call
follows from the number and your organization's configuration.
Why one event and not two
The obvious design — fire when the call hangs up, then fire again when the recording is ready — is the design we deliberately do not ship by default.
A call does not finish producing data when it stops producing audio. At the
moment the carrier reports the call terminal, we do not yet have the recording
URL, the transcript, the cost breakdown or the sentiment analysis. Those arrive over the following seconds from three different
producers. A "fire on hangup" webhook would therefore be a webhook with
transcript: null, recording_url: null and call_cost: null in it — and you
would have to poll anyway to get the parts you actually wanted. Firing again
when each part lands means the same call is delivered to you four or five times
and your handler has to reconcile partial states.
So the default subscription waits behind a barrier: the consolidated event is built and sent once, when the transcript, the cost, the recording and the analyses have all arrived — or when the barrier times out, whichever comes first.
A missing part can never strand the event
If a producer never reports, the barrier expires and the consolidated event is
sent anyway, with the missing pieces as null, processing_incomplete: true,
and a missing_fields array naming exactly what was absent. You always get
your one event. You are never left waiting on a recording that will not arrive.
The intermediate moments arrive as their own events, and they cost extra
deliveries and the reconciliation that comes with them. A dashboard Custom
Webhook receives them all; if that is more than your endpoint wants, create a
subscription with POST /v2/webhooks naming only
all_processing_completed — an agent-level subscription replaces the Custom
Webhook entirely.
Event types
event_type | Fires when | In the default subscription |
|---|---|---|
all_processing_completed | Every post-call producer has reported, or the barrier expired. The complete record. | Yes — this is the default, and the only default. |
call_ringing | The recipient's phone is ringing. The earliest live signal, and for a call nobody answers it is the only event that arrives while the call is still happening. | No |
call_started | The call has been registered and queued for dialling — before it connects. Not an answer signal: a call that is never picked up still produces one. | No |
call_completed | The call reached a terminal state, after the 60-second voicemail-settle wait. Carries duration and hangup facts, but no transcript, recording, analysis or cost. | No |
recording_completed | The recording URL is available. | No |
platform_analysis_completed | Summary, sentiment and follow-up analysis have been computed. | No |
client_analysis_completed | Your Custom Analysis variables have been extracted from the transcript. | No |
call_corrected | A verdict about an already-delivered call changed — today, only a late voicemail detection. See below. | No |
The six middle rows are the progress events. call_corrected is not one of
them; it is a terminal-class event like the consolidated one.
call_corrected
Voicemail detection is not always instantaneous. A call can complete, be
delivered to you as completed / ACCEPTED, and only afterwards be identified
as having talked to an answering machine. When that happens we rewrite the
record and send call_corrected carrying the corrected status and
sub_status in the envelope, plus previous_status, previous_sub_status,
correction_reason: "late_voicemail_verdict", call_cost: 0.0 and
answered_by_voice_mail: true.
It carries the same call_id as the event you already stored. Treat it as an
update to that row, not as a new call.
The default, and the empty-list rule
The resolved event list for any subscription is computed like this:
What you send as event_types | What you are subscribed to |
|---|---|
Omitted, or null | ["all_processing_completed"] |
[] | ["all_processing_completed"] |
["call_started", "not_a_real_event"] | ["call_started"] — the unknown name is dropped |
["nonsense", "garbage"] | ["all_processing_completed"] — nothing survived, so the default applies |
["call_started", "all_processing_completed"] | Both, as written |
There is no input that means "send me everything"
No wildcard, no "*", no "all". all_processing_completed is the name of
one specific event — the consolidated one — not a subscription to all seven.
To receive all seven, list all seven. Unrecognised names are silently dropped
(we log a warning on our side); they are never honoured and never expanded.
A subscription with enabled: false receives nothing, whatever its event list
says.
Subscribing
Two request forms exist. They write the same documents; pick whichever fits the shape of your config.
The direct form. One request creates or updates one subscription document.
| Field | Type | Required | Notes |
|---|---|---|---|
callback_url | string | Yes | Max 2048 characters. Must contain no space, tab, CR or LF. Must pass the target checks below. |
agent_id | string | null | No | The agent this subscription is for. Omit it (or send null) to create the organization-wide subscription that covers every agent without one of its own. A blank string is treated as null. |
event_types | array[string] | null | No | Defaults to ["all_processing_completed"]. See the empty-list rule above. |
method_type | string | No | "POST" (default) or "PUT". Any other verb is a 400: method_type must be one of: POST, PUT. The stored model would quietly rewrite it to POST, but /v2 refuses instead — a verb you did not ask for is not a verb you can debug. |
headers | object | No | Custom headers sent with every delivery. Max 20. See Custom headers. |
secret | string | null | No | HMAC signing key. Encrypted at rest, never returned. See Signing secret. |
enabled | boolean | No | Defaults to true. false pauses delivery while keeping the URL, headers, secret and event list intact. |
The response is the support-safe projection of the stored document — secrets and
header values are masked, and event_types is the resolved list, not the one
you sent:
secret_unavailable: true means we hold a secret we could not decrypt. It is
derived on read and never stored. When it is true, deliveries go out unsigned
— re-send the secret to fix it.
Both forms upsert on the natural key (organization_id, agent_id, callback_url),
which is a unique index. Re-subscribing the same agent to the same URL updates
that subscription; it can never create a twin that double-delivers.
Testing an endpoint
POST /v2/webhooks/test sends one signed event to an endpoint and tells you
what came back. Use it instead of placing a call: a test costs nothing, a real
call costs a call.
Send either callback_url or subscription_id; sending neither is a
400. They differ in what they prove:
| Body | What it proves |
|---|---|
{"subscription_id": "..."} | The test uses that subscription exactly as stored — its URL, method, stored headers and stored signing secret. This is the only way to prove that what is stored actually works. |
{"callback_url": "https://..."} | A URL is reachable and answers 2xx. Nothing is read from, or written to, any stored subscription. |
You can also pass event_type, agent_id, method_type, headers and
secret to override for this test only. Nothing is stored.
The response is 202 — meaning the test was attempted. Read delivered for
the outcome. A non-2xx from your endpoint is a successful test with
delivered: false, not an error from this API.
What it is and is not:
- It is a test, not a delivery. One attempt, a 10-second budget, no retry
ladder. Nothing is written to the delivery ledger — the
delivery_idabove is synthetic and will not appear inGET /v2/webhooks/deliveries. - The payload is shaped like a real event — the same envelope keys, the same
signature scheme — with synthetic values, no phone number anywhere, and one
extra key:
"test": true. Drop it on the floor, or use it to route the event away from your production handler. signed: falseis a finding, not a formality. It means no secret is configured for this subscription or organization, so your signature check can never pass. That is the same state the ledger records assigned: false.- Your endpoint's response body is deliberately not echoed — only its status code, an error class and the elapsed time.
- The URL is re-checked against its resolved addresses before the request is
made, because this endpoint dials on demand from our network. The same target
rules as a subscription apply, so a tunnel URL works and
localhostdoes not.
Which subscription receives a call
Subscriptions live at two levels, and they do not union:
The dashboard's Custom Webhook
An agent configured under Integrations & Tools → Custom Webhook receives events without any API call, and most existing integrations use exactly this. It behaves as a subscription in every respect:
| Custom Webhook (dashboard) | POST /v2/webhooks | |
|---|---|---|
| Events received | all eight by default, or the ones you tick under Send these events | any of the eight you choose |
| Signed | yes — with your organization's signing secret | yes — its own secret, else the organization's |
| Retries, delivery log | yes, identical | yes |
| Scope | that one agent | one agent, or organization-wide |
It relays the whole call, not just the ending
A URL set in the dashboard receives each event as the call moves through it —
call_ringing, call_started, call_completed, recording_completed, the
analysis events, and finally all_processing_completed with the full
47-field record. You never have to poll for a status the platform already
knows.
Expect several POSTs per call, not one. Progress events carry the
envelope only — 20 fields, no
transcript, cost or analysis, because none of that exists yet — and every
event of one call shares its call_id. Key your handler on
call_id + event_type (or event_id, which is that pair pre-hashed) and
the extra events need no other change. If you only care about the outcome,
act on all_processing_completed and acknowledge the rest with a 2xx.
To receive a narrower set, tick only the events you want under Send these
events in the same dialog, or create a subscription with
POST /v2/webhooks naming them. Both do the same thing — see below.
Send these events — narrowing without the API
The Custom Webhook dialog lists all eight events. It opens showing what is actually in force, which for a URL that has never been narrowed is all eight ticked — not an empty list, because nothing has gone wrong and there is no setting to "fix".
Ticking a subset writes a real agent-level subscription for you, pointing at the same URL. That is what makes the choice take effect: a stored subscription outranks the dashboard URL in the resolution above, so from then on the subscription is what is consulted.
Clearing every box is not "send me nothing". It deletes that subscription and returns the agent to the default — all eight again. There is no value in the vocabulary that means no events, and a cleared checkbox must never be able to switch an integration off silently.
Changing the URL re-points the subscription with it, so the two can never drift apart.
Ticking events makes this an agent-level subscription
That has the consequence described two callouts down: an agent with any enabled subscription of its own stops consulting the organization-wide one entirely. If this agent's calls also fed an org-wide endpoint, they will stop once you tick a subset here. Clear the boxes to undo it.
Creating any subscription for that agent replaces it
The Custom Webhook is the last step in the resolution above, so a stored subscription — agent-level or organization-wide — takes over completely and the dashboard URL stops receiving anything.
That is what prevents double delivery, and it is one-way: there is no configuration in which both fire for the same call. If you create a subscription and the dashboard endpoint goes quiet, this is why.
Subscribing an agent removes its org-wide fallback entirely
If an agent has even one enabled subscription of its own, the org-wide subscription is not consulted for that agent — not even for event types the agent-level subscription does not want.
Subscribe an agent to call_started alone and you will receive
call_started for it and nothing else. Its calls will stop appearing on
your org-wide all_processing_completed endpoint. That is the intended
behaviour: an agent-level configuration is a complete override, so the
narrow subscription you wrote is the one you get.
Only enabled documents count when deciding whether the agent "has a configuration". Pause an agent's only subscription and it falls back to the org-wide one.
agent_id in the resolution is the original, pre-A/B-resolution agent id. If
the call was served by an A/B variant, routing still follows the agent you
dialled.
Propagation
Scope lookups are cached for 60 seconds per process, and the organization's webhook feature flag for 300 seconds. The API runs across several processes, so an edit is effective immediately in the process that served it and within 60 seconds everywhere else. Plan a subscription change one minute ahead of the traffic that depends on it.
Custom headers
Every header you configure is sent on every attempt, after ours. Use them for your endpoint's own authentication, tenant routing, or tracing.
Header names and values are validated on the way in. The stored model would
repair each of these silently; /v2 refuses loudly instead, because a
header you configured and never see sent is indistinguishable, from a support
ticket, from a bug in the delivery engine. Every row below is a 400 on the
request that introduced it, and nothing is written:
| Rule | Effect |
|---|---|
Name must be a valid HTTP token — letters, digits, and the characters ! # $ % & ' * + . ^ _ ` | ~ - (the backtick and the vertical bar are both allowed) | 400 — Invalid header name '...'. |
Name is content-type, content-length or host (any casing) | 400. You cannot lie about the body we send. |
Name starts with x-graine- (any casing) | 400. A stored header can never forge X-Graine-Signature or any other platform header. |
Value is null | 400 — Header '...' must have a value. |
| Value contains CR or LF | 400. Header injection is not possible, and the value is never echoed back in the error — it is a credential more often than not. |
| Value longer than 4096 characters | 400. Not truncated. |
| More than 20 headers | 400 — At most 20 headers may be configured. Not trimmed to the first 20. |
Header values are encrypted at rest with AES-256-GCM — every value, not a
subset guessed to look sensitive — using the same helper that protects stored
phone numbers. They are never logged and never returned by the API; the
projection shows "***". If a value cannot be decrypted on read, the header is
dropped from the delivery rather than sent as ciphertext.
Signing secret
Set secret on the subscription and every delivery carries an
X-Graine-Signature header. If the subscription has no secret, your
organization's webhook signing secret is used. If neither resolves, deliveries go
out unsigned and the delivery record says so.
The full verification recipe, with runnable Node and Python, is in Error handling — Verifying the signature.
Endpoint requirements
Your endpoint must satisfy all of the following, or deliveries fail.
Scheme http or https. Nothing else is accepted. Use https in
production — the body contains transcripts and phone numbers.
Publicly resolvable, and not an internal address. The host is checked when you subscribe and re-checked against its resolved IP addresses on every delivery attempt, because DNS can be re-pointed between attempts. Rejected in both places:
- hostnames
localhost,localhost.localdomain,ip6-localhost,ip6-loopback,metadata,metadata.google.internal,metadata.goog,instance-data - any host ending in
.localhost,.local,.internal,.localdomain - any address that resolves to loopback, link-local (this covers the
169.254.169.254cloud metadata service), RFC 1918 private, reserved, multicast or unspecified space — including IPv4-mapped and 6to4 IPv6 forms, which are unwrapped before the check
A DNS lookup that simply fails is not a rejection. It surfaces as a retryable error, so a transient resolver problem does not permanently kill your subscription.
Answers POST (or PUT, if you configured it) with a 2xx.
200 through 299 inclusive is the only success. Everything else is retried —
including 3xx. We never follow redirects, so pointing your subscription at a
URL that 301s to the real handler will fail four times and give up. Subscribe
the final URL.
Responds within 30 seconds, with the TCP connection established within 10.
Acknowledge first, process afterwards: write the body to a queue and return
200 immediately. A handler that transcribes, enriches or forwards before
responding will eventually exceed the timeout under load and start collecting
duplicate deliveries.
Accepts a body up to 1,000,000 bytes. Long transcripts are truncated before they exceed that (see Body size), but your endpoint must not reject large posts.
Is idempotent on event_id. At-least-once delivery is the contract. See
Idempotency.
Local development
Because private and loopback targets are blocked, http://localhost:4000 will
never receive a delivery from the hosted API. Use a public tunnel
(ngrok, cloudflared) and subscribe the tunnel's public URL. Self-hosted
deployments have an environment escape hatch for private targets; it is for
local development only and is off everywhere else.
Pausing, deleting, and the organization switch
| Action | Effect |
|---|---|
enabled: false on a subscription | Deliveries stop. URL, headers, secret and event list survive. This is the reversible half of deleting — use it to mute an endpoint during maintenance. |
| Delete one subscription by id | Removed. Ids are scoped to your organization, so an id alone can never reach another tenant's subscription. |
| Delete the whole scope for an agent | Every subscription for that agent is removed; the agent falls back to the org-wide subscription, if one exists. |
PATCH /v2/agents/{agent_id}/event-subscriptions with "event_subscriptions": [] | Same as deleting the whole scope for that agent. |
| Organization webhook flag off | All webhook delivery for the organization stops, at the source. Nothing is queued for later. |
The organization flag fails open: a missing organization config, a missing flag, or a config we cannot read all mean webhooks are on. The flag exists so an operator can deliberately turn delivery off — a storage hiccup must not do that for every customer at once.
What this engine does not send
Stated plainly so you do not build against something that is not there:
- No replay endpoint. A delivery that exhausts its retries is not
re-sendable through the API. Reconstruct from
GET /v2/calls/{execution_id}. - No per-event ordering guarantee. If you subscribe to progress events, a
retried
call_startedcan arrive aftercall_completed. Order by the payload's own fields, never by arrival. - No inbound-call-only or agent-error event types. The seven above are the complete set.
- No delivery of the raw carrier status string.
statusandsub_statusare always from our own closed vocabulary — see Payloads.

