Graine AI

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.

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_typeFires whenIn the default subscription
all_processing_completedEvery post-call producer has reported, or the barrier expired. The complete record.Yes — this is the default, and the only default.
call_ringingThe 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_startedThe 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_completedThe 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_completedThe recording URL is available.No
platform_analysis_completedSummary, sentiment and follow-up analysis have been computed.No
client_analysis_completedYour Custom Analysis variables have been extracted from the transcript.No
call_correctedA 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_typesWhat 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.

curl -X POST https://api.graine.ai/v2/webhooks \
  -H "Authorization: Bearer gat_7f3c9a21b4e85d0c6a19f2734b8e5c60" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agent_5b7d21e9c8a34f60",
    "callback_url": "https://hooks.example.com/graine/calls",
    "event_types": ["all_processing_completed"],
    "method_type": "POST",
    "headers": {
      "X-Tenant": "acme-prod",
      "Authorization": "Bearer your-own-endpoint-token"
    },
    "secret": "whsec_2f8c41a09b7e4d6188aa3c5e7b90d412",
    "enabled": true
  }'
FieldTypeRequiredNotes
callback_urlstringYesMax 2048 characters. Must contain no space, tab, CR or LF. Must pass the target checks below.
agent_idstring | nullNoThe 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_typesarray[string] | nullNoDefaults to ["all_processing_completed"]. See the empty-list rule above.
method_typestringNo"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.
headersobjectNoCustom headers sent with every delivery. Max 20. See Custom headers.
secretstring | nullNoHMAC signing key. Encrypted at rest, never returned. See Signing secret.
enabledbooleanNoDefaults 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:

{
  "subscription_id": "e0c9a4f2-3b17-4d85-9a6e-2f70c8b1d534",
  "organization_id": "organization-live-4821",
  "agent_id": "agent_5b7d21e9c8a34f60",
  "event_types": ["all_processing_completed"],
  "callback_url": "https://hooks.example.com/graine/calls",
  "method_type": "POST",
  "headers": {
    "X-Tenant": "***",
    "Authorization": "***"
  },
  "secret": "***",
  "has_secret": true,
  "secret_unavailable": false,
  "enabled": true,
  "created_at": "2026-08-27T09:41:22.518Z",
  "updated_at": "2026-08-27T09:41:22.518Z",
  "created_by": "gat_7f3c9a21"
}

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.

curl -X POST https://api.graine.ai/v2/webhooks/test \
  -H "Authorization: Bearer gat_7f3c9a21b4e85d0c6a19f2734b8e5c60" \
  -H "Content-Type: application/json" \
  -d '{ "subscription_id": "e0c9a4f2-3b17-4d85-9a6e-2f70c8b1d534" }'

Send either callback_url or subscription_id; sending neither is a 400. They differ in what they prove:

BodyWhat 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.

{
  "accepted": true,
  "delivered": true,
  "http_status": 200,
  "error_class": null,
  "duration_ms": 214,
  "signed": true,
  "signature_header": "X-Graine-Signature",
  "event_type": "all_processing_completed",
  "event_id": "0d2f1c7a-9e64-4b30-8f52-1a7c6d38e905",
  "delivery_id": "test_9c1e4a7b30d85f26a4c07be1",
  "callback_host": "hooks.example.com",
  "method_type": "POST",
  "sent_header_names": ["Content-Type", "User-Agent", "X-Graine-Signature", "..."],
  "body": { "event_type": "all_processing_completed", "test": true, "...": "..." }
}

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_id above is synthetic and will not appear in GET /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: false is 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 as signed: 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 localhost does not.

Which subscription receives a call

Subscriptions live at two levels, and they do not union:

1. subscriptions for (your org, this call's agent_id)   ← if ANY enabled one exists, ONLY these are used
2. subscriptions for (your org, no agent)               ← the org-wide fallback
3. the agent's own Custom Webhook URL                   ← set in the dashboard
4. nothing

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 receivedall eight by default, or the ones you tick under Send these eventsany of the eight you choose
Signedyes — with your organization's signing secretyes — its own secret, else the organization's
Retries, delivery logyes, identicalyes
Scopethat one agentone 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:

RuleEffect
Name must be a valid HTTP token — letters, digits, and the characters ! # $ % & ' * + . ^ _ ` | ~ - (the backtick and the vertical bar are both allowed)400Invalid 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 null400Header '...' must have a value.
Value contains CR or LF400. 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 characters400. Not truncated.
More than 20 headers400At 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.254 cloud 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

ActionEffect
enabled: false on a subscriptionDeliveries 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 idRemoved. Ids are scoped to your organization, so an id alone can never reach another tenant's subscription.
Delete the whole scope for an agentEvery 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 offAll 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_started can arrive after call_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. status and sub_status are always from our own closed vocabulary — see Payloads.

Next