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 — and, for a call the agent rang in your app, how a ring ended
with no conversation), 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 every event type for that agent, unless you narrow them under Send these events. This is how most integrations are set up.
Subscriptions — POST /v2/webhooks
Create it over the API. Adds a choice of exactly which event types you receive, an organization-wide scope, your own signing secret, custom headers, and programmatic management. Reach for it when you want fewer 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 would have none of those. 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.
POST /v2/calls takes no provider either, and sending one is rejected the same way.
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 deliberately not the 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
separately over the following seconds. 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 one of those parts never arrives, 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 | Default for a POST /v2/webhooks subscription |
|---|---|---|
all_processing_completed | Every part of post-call processing has arrived, 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. Carries status: "queued". Not an answer signal, and not a rename of one: it is sent exactly once, at this moment, so no event marks the answer. 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_declined | A call the agent rang in your app was declined on the user's phone. See below. | No |
call_missed | A call the agent rang in your app rang out unanswered. See below. | No |
call_expired | A scheduled in-app call could not ring in time — outside the calling hours, held for credit, or the agent was switched off — so it was not rung. See below. | No |
call_corrected | A verdict about an already-delivered call changed — today, only a late voicemail detection. See below. | No |
The rows from call_ringing to call_expired are the progress events.
call_corrected is not one of them; it is a terminal-class event like the
consolidated one.
In-app calls: call_declined, call_missed, call_expired
When Graine rings your app for a scheduled call and the ring ends without a
conversation, the call gets one of these, beside its consolidated
all_processing_completed. Each carries the
envelope alone: call_type is
inapp_call, to_number, from_number and call_sid are null, and status
and sub_status say how the ring ended — busy / busy_here (declined),
no_answer / no_answer (missed) and failed / call_failed (expired).
call_id is the scheduled call's id, the same id the app's SDK reports. An
in-app call that is answered sends the conversation's own events; one that rang
sends call_ringing first. A ring that could not be sent at all — no device to
ring, or a failure — sends only the consolidated event, with status failed.
Payloads:
Progress events.
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 to no_answer and send call_corrected carrying the corrected
status: "no_answer" and sub_status: "voicemail_detected" in the envelope,
plus previous_status: "completed", previous_sub_status (null today),
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 a subscription you create with POST /v2/webhooks
or PATCH /v2/webhooks/{subscription_id} 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"] | Nothing changes: the request is a 400 naming the unknown event type and listing the valid ones |
["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 of them.
To receive every event, list every one. An unrecognised name is refused with a
400, never dropped, honoured or expanded.
A subscription with enabled: false receives nothing, whatever its event list
says.
Subscribing
Two request forms exist. They write the same subscriptions; pick whichever fits the shape of your config.
The direct form. One request creates or updates one subscription.
| 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. It is refused rather than quietly replaced with POST — 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. Never returned; stored encrypted wherever field encryption is switched on. 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 stored subscription, with secrets and header values 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 (organization_id, agent_id, callback_url), and no two
subscriptions can share that combination. 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 log — 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. The values are placeholders, not a real outcome:call_idistest_plus 16 hex characters,statusiscompletedwith anullsub_status, andcalling_sourceisapi— so a test proves your parser and your signature check, not your outcome rules. signed: falseis a finding, not a formality. Your organization's signing secret is created the first time one is needed, sofalsemeans no secret could be resolved — the stored one can no longer be decrypted, or the organization secret could not be created — and your signature check can never pass. That is the same state the delivery log records assigned: false.- Your endpoint's response body is deliberately not echoed — only its status
code, an error class, a one-line
error_messagewhen the attempt failed, 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 | every event type by default, or the ones you tick under Send these events | any of them 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. To narrow what actually runs a
workflow — by event, outcome, or the reason behind it — see
Filtering which events run a workflow.
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 every event as a timeline — during the call,
after the call, afterwards, and in-app calls — and each row says when it fires rather
than restating its identifier. Two of those names mislead if you read them
alone: Queued for dialling (call_started) fires before the phone rings
and is not an answer signal, and Call ended (call_completed) includes
busy and no answer. The wording is served by the API, so it cannot drift from
the events actually published.
It opens showing what is actually in force, which for a URL that has never been narrowed is every event 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 — every event 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 subscriptions 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
A subscription change can take up to 60 seconds to apply to every delivery, and a change to your organization's webhook switch (below) up to 5 minutes. 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. /v2 refuses each of
these rather than repairing it silently, because a header you configured and
never see sent looks exactly like a delivery bug. 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 stored encrypted with AES-256-GCM wherever
field encryption is
switched on — every value, not a subset guessed to look sensitive — the same
protection stored phone numbers get. On Graine's shared cloud that layer is built
but not yet on, so for now they rely on our database provider's encryption at
rest. They are never logged and never returned by the API, which shows "***" in
their place. 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. Use a public tunnel (ngrok, cloudflared) and
subscribe the tunnel's public URL.
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 /v2/webhooks/{subscription_id} | Removed; answers 204 with no body. Ids are scoped to your organization, so an id alone can never reach another tenant's subscription. |
PATCH /v2/agents/{agent_id}/event-subscriptions with "event_subscriptions": [] | Every subscription for that agent is removed; the agent falls back to the org-wide subscription, if one exists. |
| Webhooks switched off for the organization | All webhook delivery for the organization stops, at the source. Nothing is queued for later. |
The organization switch fails open: if it cannot be read, webhooks stay on. It exists so delivery can be turned off deliberately — a passing fault must never do that.
What webhooks do not do
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 events above are the complete set.
- No delivery of the raw carrier status string.
statusandsub_statusare always from our own closed vocabulary — see Payloads.

