Webhook Error Handling
The exact retry schedule and timeouts, what counts as a success, what happens when retries run out, deduplicating on the idempotency key, and verifying the X-Graine-Signature header with runnable Node and Python.
Webhook delivery is at-least-once. A well-behaved endpoint will occasionally receive the same event twice, and must be idempotent. This page is the contract your handler should be written against.
The retry schedule
Four attempts maximum, at fixed offsets measured from the first attempt — not from the previous one.
| Attempt | Fires at | Gap since the previous attempt |
|---|---|---|
| 1 | first attempt + 0 s (immediately on enqueue) | — |
| 2 | first attempt + 10 s | 10 s |
| 3 | first attempt + 60 s | 50 s |
| 4 | first attempt + 120 s | 60 s |
The offsets are absolute, not cumulative gaps
A common misreading is "0, then 10 later, then 60 later, then 120 later", which would put the last attempt at 190 seconds. It does not. Everything is over within roughly two minutes of the first attempt. If your endpoint is down for a five-minute deploy, every event that fired during it is lost — plan for that, or subscribe an always-on queue endpoint that forwards internally.
There is no exponential backoff, no jitter and no extended retry window. Two minutes is the whole budget.
Scheduling guarantees
Delayed attempts are persisted jobs, not in-memory timers, so a process restart between attempt 2 and attempt 3 does not lose attempt 3. Two safeguards:
- Never early, never in the past. A scheduled time that has already passed when the job is created — for example after a restart — is clamped to one second from now rather than firing retroactively.
- A reconciliation sweep runs every 120 seconds and re-drives any pending delivery whose scheduled time is more than 60 seconds in the past, in deployment-tuned batches (600 per pass in the standard production configuration). The worst case for a delivery caught by an infrastructure hiccup is a late attempt, never a lost one.
When an attempt succeeds, the remaining scheduled attempts are cancelled immediately.
Timeouts
| Timeout | Value |
|---|---|
| Total request timeout | 30 seconds |
| Connection timeout | 10 seconds |
The 30 seconds covers connect, send, wait and read. An endpoint that takes longer
records a timeout and the attempt is retried.
Redirects are not followed. The HTTP client is deliberately configured to ignore any proxy environment configuration, so deliveries always go directly to the host you subscribed.
What counts as success
HTTP 200 through 299 inclusive. Nothing else.
| Response | Result |
|---|---|
200, 201, 202, 204, any 2xx | Success. Remaining attempts cancelled. |
301, 302, 307, 308, any 3xx | Failure — retried. |
Any 4xx | Failure — retried. A 401 from your endpoint is retried three more times, then given up on. |
Any 5xx | Failure — retried. |
| Timeout, TLS error, DNS failure, connection refused | Failure — retried. |
A 3xx is a failure, not a redirect to follow
Because redirects are never followed, subscribing a URL that redirects to your real handler produces four failed attempts and a dead event — while your redirect logs show four successful hits. Subscribe the final URL. This is a security property, not an omission: following redirects would let a compromised or misconfigured endpoint bounce a delivery containing transcripts and phone numbers to a host we never vetted.
Your response body is not inspected. Returning 200 with
{"error": "could not process"} is recorded as a success and the event will not
be retried. If you cannot process an event, return a 5xx.
When retries are exhausted
After the fourth failed attempt the delivery is finalised as failed and carries
a machine-readable reason:
The suffix is the classification of the last attempt: http_<status> when we
got an HTTP response, otherwise one of these error classes.
error_class | Cause |
|---|---|
timeout | The endpoint did not respond within 30 seconds. |
tls_error | Certificate or TLS handshake failure. |
dns_error | The hostname could not be resolved. |
connect_error | TCP connection refused or unreachable. |
protocol_error | A malformed HTTP response. |
proxy_error | A proxy layer failed. |
transport_error | Any other transport-level failure. |
http_error | A generic HTTP client failure. |
cancelled | The attempt was cancelled in flight. |
unexpected_error | An unclassified failure. |
url_rejected | The target resolved to a blocked address at attempt time. Not retried — the delivery fails immediately. |
abandoned | The delivery could not be completed and was closed out. |
There is no replay endpoint
An exhausted delivery is gone. It is not queued, not resent later, and cannot
be re-triggered through the API. Recover the data by calling
GET /v2/calls/{execution_id} with the call_id you missed.
This is why the recommended handler shape is "acknowledge to a durable queue, process afterwards" — the two-minute retry budget is not a substitute for your own durability.
Deliveries that are never attempted
Some events are recorded as suppressed rather than attempted. Suppression
records are kept for the consolidated event and call_corrected only, so that a
missing terminal webhook always has an explanation.
| Reason | Meaning |
|---|---|
not_subscribed | No enabled subscription wanted this event type for this agent. Check the scope resolution rules — an agent-level subscription overrides the org-wide one entirely. |
webhooks_disabled | Webhook delivery is switched off for your organization. |
no_callback_url | The subscription has no callback URL. |
url_rejected | The callback URL failed the target checks — a blocked host, a private address, a bad scheme, or an over-long or whitespace-bearing URL. |
Every delivery record — attempted or suppressed — is retained for 30 days, then removed automatically.
Duplicate deliveries
The delivery scheduler runs on every application process. Each attempt takes a distributed lock before firing, so exactly one process sends it. If a lock is lost to an expiry or a network partition, the cost is one duplicate POST — never a lost delivery.
That trade is deliberate: losing an event is unrecoverable, whereas a duplicate is free if you deduplicate. Which brings us to the next section.
Idempotency
Every event carries a stable identifier derived deterministically from the call and the event type:
Which means:
- The same event always has the same
event_id. All four delivery attempts carry byte-identical bodies. If the same event is ever emitted twice, itsevent_idis unchanged. (call_id, event_type)is the natural idempotency key, andevent_idis simply that pair pre-hashed. Deduplicate on either — they are equivalent. Useevent_id; it is one column instead of two and it is also available as theX-Graine-Event-Idheader, so you can dedupe before parsing the body.call_idalone is not a key. One call can legitimately produceall_processing_completedand latercall_corrected. Keyed oncall_idalone you would discard the correction.
Do not use timestamp, delivery_id or the signature to deduplicate
timestamp is the build time and differs between a first emit and a re-emit.
X-Graine-Delivery-Id identifies the delivery row, not the event.
X-Graine-Signature changes on every attempt, because its embedded
timestamp is taken per attempt. Only event_id is stable.
A worked handler
Insert the event_id into a unique-keyed table first. If the insert conflicts,
you have already seen this event — acknowledge and stop.
This is your database, not ours. Graine stores nothing on your side and
requires no particular datastore — the schema below is an example of a table
you would create in your own system to deduplicate events. PostgreSQL is
used here only because its ON CONFLICT DO NOTHING states the idea in one
line. Any store with a unique constraint works: MySQL INSERT IGNORE,
MongoDB a unique index, DynamoDB a conditional put, Redis SET NX. What
matters is that claiming an event_id is atomic, so two concurrent
deliveries of the same event cannot both proceed.
Progress events have no ordering guarantee
If you subscribe to progress events, a retried call_started can arrive after
call_completed. Never let a later-arriving event with a non-terminal status
overwrite a terminal one. Ordering by the envelope's own fields, or refusing
non-terminal transitions once a call is terminal, both work. Staying on the
default single consolidated event avoids the problem entirely.
Verifying the signature
When a signing secret resolves for the subscription, every delivery carries:
tis the Unix timestamp, in seconds, at which that attempt was signed.v1is a lowercase hex HMAC-SHA256 digest.
The signed material is the timestamp, a literal ., then the exact raw
request body bytes:
Sign the raw body, never a re-serialised object
The bytes we sign are the bytes on the wire — the payload is serialised once and never rebuilt. If you parse the JSON and re-encode it before verifying, key ordering, whitespace and float formatting will all differ and every signature will appear invalid. Capture the raw body before your JSON middleware touches it. Both examples below show how.
The timestamp is inside the signed material specifically so a captured request
cannot be replayed with a fresh header: changing t invalidates v1. Reject any
delivery whose t is outside a tolerance window — five minutes is a reasonable
choice, and comfortably larger than the two-minute retry budget.
The signature changes on every attempt
t is taken per attempt, so all four attempts of the same event have four
different signatures — while the body and event_id stay byte-identical.
Verify each delivery on its own header; deduplicate on event_id.
Runnable examples
If no signature arrives
The X-Graine-Signature header is absent entirely — not empty, not a
placeholder — when no signing secret resolved: a stored secret could not be
decrypted (the subscription projection reports secret_unavailable: true in
that case), or the organization key could not be minted.
Where to get your secret:
- Dashboard Custom Webhook — open the agent's Custom Webhook dialog and
press Reveal next to Signing secret, or call
GET /v2/webhooks/signing-secret(requireswebhooks:write— holding this value is the ability to forge a delivery to yourself, so a read-only key cannot fetch it). The key is minted on first read and stable afterwards; fetching it never rotates it. - API subscription — the
secretyou supplied onPOST /v2/webhookswins for that subscription; without one, the organization key above signs it.
Reject unsigned deliveries in production. If you are receiving them
unexpectedly, re-send the secret on the subscription.
Headers on every delivery
Ours are sent first, then your custom headers.
| Header | Value | Stable across attempts |
|---|---|---|
Content-Type | application/json | Yes |
Accept | application/json | Yes |
User-Agent | Graine-Webhooks/1.0 | Yes |
X-Graine-Event | The event_type | Yes |
X-Graine-Event-Id | The event_id — dedupe on this | Yes |
X-Graine-Delivery-Id | Identifies the delivery record. Quote it in support requests. | Yes |
X-Graine-Call-Id | The call_id | Yes |
X-Graine-Attempt | 1, 2, 3 or 4 | No — it is the attempt number |
X-Graine-Timestamp | Unix seconds for this attempt | No |
X-Graine-Signature | t=<unix>,v1=<hex>. Omitted when no secret resolved. | No |
Your custom headers are appended afterwards, minus any whose name is
content-type, content-length or host, any beginning x-graine-, and any
whose name or value contains a CR or LF. A configured header can therefore never
forge our signature or misstate the body length.
Body size and truncation
The body is compact JSON (no spaces after separators), UTF-8, non-ASCII characters left as-is rather than escaped. The maximum body is 1,000,000 bytes.
Nearly every payload is far below that. When one is not — a very long transcript — it is reduced in this order, and each step is applied only if the previous one was not enough:
Transcript turns are dropped from the end. A binary search finds the longest
opening run of turns that fits. transcript_status becomes "truncated". The
beginning of a conversation is kept because it is what a human reads first, and
what identifies the call.
The transcript is dropped entirely — set to null, with transcript_status
still "truncated".
Analysis prose is trimmed: platform_analysis.summary to 4,000 characters
and platform_analysis.sentiment_analysis to 2,000.
The payload is sent anyway, oversized, and logged on our side. We would rather deliver a large event than drop it.
The one case where transcript is an empty array
If not even the first turn fits, step 1 settles on a zero-length prefix and you
receive "transcript": [] with "transcript_status": "truncated". It is the
only situation in which transcript is [] rather than null. Treat
transcript_status: "truncated" as the authoritative signal and fetch the full
transcript with GET /v2/calls/{execution_id}/transcript.
The same bytes are re-sent on all four attempts, so a body that verified on attempt 1 verifies identically on attempt 4 — only the signature's timestamp differs.
Debugging a missing webhook
Confirm the call itself reached a terminal state. Call
GET /v2/calls/{execution_id}. If the call is still in progress, the
consolidated event has not been built yet.
Check the scope resolution. This is the most common cause. If the agent has any enabled subscription of its own, the org-wide subscription is not consulted for that agent — see Which subscription receives a call.
Check the resolved event list. A subscription that resolved to
["all_processing_completed"] gets no call_started. Unknown event type names
are dropped silently — the projection returned by the subscription API shows the
resolved list, so compare that against what you meant to send.
Check your endpoint returned a 2xx. A 3xx or a body-level error with a
200 are both frequent misconfigurations, in opposite directions: the first
fails while your logs look healthy, the second succeeds while your processing
did not.
Wait 60 seconds after a subscription change before concluding it did not apply. Scope lookups are cached for up to a minute per process.
Quote the X-Graine-Delivery-Id, or the call_id and event_type, when
contacting support. Delivery records are retained for 30 days and carry every
attempt, its HTTP status, its duration and its error class.

