Skip to content

Delivery ​

The request we send ​

A POST with content-type: application/json by default (a template may set another), and this user agent:

user-agent: DripcelWebhooks/1.0 (+https://github.com/standard-webhooks)

Headers ​

HeaderAlwaysMeaning
webhook-idYesUnique per delivery. Deduplicate on this.
webhook-timestampYesUnix seconds. The time of this attempt, not of the event.
webhook-signatureYesSpace-separated v1,<digest> entries. See Signatures.
x-event-idNoThe event's id, the same on every endpoint it reaches. Absent on a test.
x-event-typeYesThe event type, e.g. call_outcome.submitted, so you can route on a header.
x-event-timeYesISO 8601. When the event happened.
x-event-testNotrue only on a test delivery. Absent — not false — on real ones.

The three webhook-* headers are the Standard Webhooks specification's. The x-event-* headers are ours and additive; we never add one under webhook-, which is the specification's to extend.

One event reaches one endpoint once, so on a single endpoint webhook-id and x-event-id dedupe alike. x-event-id is the one to use when several of your endpoints receive the same event and you want to recognise it across them.

x-event-time exists because a custom body template may omit event.timestamp, and a client with no event time cannot order a stream that is explicitly unordered. You cannot recover it from webhook-timestamp, which moves on every retry.

You may also configure custom headers per endpoint — an API key your intake requires, for instance. See Templates for what is reserved.

Holding a delivery until a later time ​

An endpoint may carry a delivery time expression: a Liquid template that names when this event's delivery should be sent. The operator writes it on the endpoint's form, and the same expression is evaluated for every event the endpoint receives, against the same fields as the body.

liquid
{{ outcome.callback_at }}
  • Blank, or an output naming a past time, sends immediately. So does an expression that cannot be read: the delivery is sent now, and the log records the reason under Delivery time ignored. A lead is never lost because its requested time was malformed.
  • At most 30 days. A later time is pulled back to 30 days after the event fanned out, and the delivery's Scheduled for time is the capped instant.
  • Full ISO 8601 with a zone, or epoch seconds. 2026-10-24T15:00:00Z, 2026-10-24T17:00:00+02:00, or 1790000000. A wall clock without a zone is refused, because we cannot know which zone the customer meant.
  • The time is a snapshot. It is rendered once, when the event fans out, and never re-read. Editing the endpoint, opting the contact out or deleting them afterwards does not move or stop a delivery already held. Cancel it from the log if it must not go.
  • A test delivery ignores the delivery time and sends at once.
  • Nothing is ordered. A held delivery arrives after newer events; order by x-event-time.

The log shows a held delivery as Scheduled until its time. Its detail page offers Send now (send without waiting) and Cancel (withdraw it, recorded as cancelled; Retry can still send it). Only a delivery that has not been attempted can be cancelled — one already in the retry ladder has partly happened.

Timeouts ​

10 seconds per attempt. A slower response is abandoned and counted as a failure.

This is tighter than the specification's suggested 15–30 seconds, because every attempt occupies one of a small number of shared senders, and a slow endpoint would otherwise hold one up for everybody.

Acknowledge first, process afterwards. If your handler does real work inline it will eventually cross 10 seconds, and you will be retried for work you already completed.

What counts as success ​

By default, 2xx, 302 and 303 count as delivered. The latter two support receivers that acknowledge queued work with a redirect status. Other responses retry:

  • 4xx — including 400, 401 and 422. We cannot tell a permanently malformed payload from a temporarily misconfigured receiver, so we retry.
  • 5xx
  • A timeout, a connection failure, a TLS error, or an unresolvable host

The one exception is 410 Gone, which permanently disables the endpoint. Use it when you decommission a receiver; use any other status when you are merely having a bad day.

Redirects are never followed. Except for 302 and 303, they retry by default. A redirect target is not the host we vetted when the endpoint was configured, so following one would be the cheap version of the SSRF attack the URL rules exist to prevent. Configure the endpoint with its final URL — in particular, an http: → https: redirect will not save a misconfigured endpoint. The delivery log shows the redirect's location header, so you can see where it pointed. A response rule can accept a redirect status, but cannot cause its target to be followed.

Custom response rules ​

An operator with webhook management access configures them from the Response rules card on an endpoint's dashboard page, whose Edit opens the endpoint's own Response rules page. Existing endpoints retain default handling until rules are saved. Rules apply only to HTTP responses, not to network failures.

The page offers four choices:

  • Status code only — the default: any 2xx, 302 or 303 is delivered and anything else retries. The body is never read.
  • Stop on client errors — the same, but a 4xx other than 408 or 429 stops.
  • JSON success flag — a 2xx whose JSON body has "success": false retries rather than counting as delivered. A 2xx without a JSON body retries too.
  • Custom rules — your own, starting from whichever choice was selected before.

A preset is only a starting point: saving one saves its rules, and the dashboard shows an untouched preset by name.

Rules run in order; the first match wins. Each rule chooses:

  • Delivered — accepted; no retry. Resets the endpoint's failure streak.
  • Retry — use the existing retry ladder and limits. Counts as an endpoint failure.
  • Stop — permanent failure for this delivery only; no automatic retry. Does not disable the endpoint or increment or reset its failure streak.

An explicit fallback, If no rule matches, applies when none does. The starter rules reproduce default HTTP acceptance and use Retry as their fallback. Reorder a body-specific rejection rule ahead of a broad 2xx success rule if both can match.

Conditions can inspect status codes, response headers, JSON fields and response text, combined with nested all (AND) or any (OR) groups. Comparisons include equality, inequality, text contains, numeric ordering and inclusive ranges, membership lists, and JSON/header presence. JSON pointers use RFC 6901: for example, /result/code, /items/0/id, and /a~1b for an object key named a/b.

JSON comparisons preserve types: false is different from "false"; 123 is different from "123". Null and a missing field are different. A missing field does not match ordinary comparisons, including inequality; check for absence explicitly. Text comparisons are case-sensitive unless Ignore case is selected. Header names are always case-insensitive. Conditions and groups short-circuit left to right, so put status guards before body conditions where appropriate. Numeric comparisons use JavaScript's JSON number precision; use JSON strings for very large identifiers or values needing exact decimal representation.

Required response bodies must be complete UTF-8 text, at most 64 KiB, read within the remaining 10-second request deadline. A malformed required JSON body, unsupported/binary body, interrupted read, body over the limit, invalid saved policy, or failure to load the latest rules causes an evaluation-error retry. It does not fall through to a later success rule or count against endpoint health; the usual attempt limit still applies. A body not needed by a reached condition cannot change a status-only decision.

HTTP 410 always disables the endpoint, regardless of custom rules. Rules cannot change retry limits, follow redirects, or classify a DNS/TLS/timeout failure as delivered. Retry-After still applies to retry decisions.

Saving rules affects the next classification, including a request already in flight and pending retries. Each evaluation uses one consistent saved revision. Saving does not reclassify completed deliveries or automatically restart stopped ones. A stopped delivery can be retried manually; it then uses the latest rules. Choosing Status code only and saving restores default handling. Conflicting saves from another tab are refused rather than silently overwriting them.

Use Test a response to supply a synthetic status, headers and body against unsaved rules. This does not call the endpoint and changes no deliveries or endpoint health. A delivery's detail page records each attempt's classification, matching rule name/id, policy revision and evaluation-error reason; it is not an archive of previous rule definitions.

Policies allow at most 20 rules, 128 total condition nodes, 4 nesting levels, and 32 KiB of serialized configuration. They are declarative: no JavaScript, regular expressions, wildcard paths, or implicit response coercion. Limits are RESPONSE_RULES.LIMITS in src/lib/const/response_rule.const.ts.

The retry ladder ​

Ten attempts spanning about 3.1 days. Each wait has ±10% jitter applied, so a receiver coming back from an outage does not get every missed delivery in the same second.

AttemptWait before itApprox. time since the event
1noneimmediate
25 seconds5 seconds
35 minutes~5 minutes
430 minutes~35 minutes
52 hours~2.6 hours
65 hours~7.6 hours
710 hours~18 hours
814 hours~1.3 days
920 hours~2.1 days
1024 hours~3.1 days

The shape is intentional: quick twice for a redeploy or a blip, widening fast, then roughly daily once a human has to notice something.

After the tenth attempt the delivery is marked given up and is not retried again. It stays in the delivery log, where it can be retried by hand.

For a delivery held by a delivery time, the ladder starts when it is sent, not when the event happened.

BACKOFF_SEC in src/lib/const/webhook.const.ts.

Slowing us down ​

Send retry-after on a failure response and we will honour it, capped at 24 hours. This is the cooperative way to shed load — better than letting requests time out, which costs you 10 seconds of our patience and one of your ten attempts either way.

Idempotency ​

Delivery is at-least-once. You will receive duplicates. A 2xx that gets lost on the way back to us produces a retry of a request you already handled successfully, and no amount of care on either side eliminates that.

Deduplicate on webhook-id. Store the ids you have processed and drop repeats. A few days' retention covers the full ladder.

If you own several endpoints subscribed to related events, event.id in the body is shared across one event's fanout, so it is the key for collapsing a fanout — not for deduplicating a retry, which is webhook-id.

Resending past events ​

Your operator can resend an endpoint every event from the last 24 hours, 7 days or 30 days that it has no delivery for — because it was created later, was disabled, or filtered the event out before its filter was widened. Each arrives as a new delivery with its own webhook-id, rendered under the endpoint's current template and filters, with x-event-time still the moment the event happened. Its delivery time is evaluated at replay, so a time already past sends at once. Events the endpoint already received, or gave up on, are not sent again.

For those, read the record instead: GET /calls with updated_since returns every call and its outcome as they stand now, under the same field names, whatever happened to the deliveries.

Resent events arrive in bulk and out of order with live ones, so the ordering advice matters most here. event.id is the same as on any other endpoint the event reached.

WEBHOOKS.EVENT.REPLAY_WINDOWS_SEC in src/lib/const/webhook.const.ts.

Ordering ​

There is none. Two events about the same call can arrive in either order, and a retried delivery can arrive after a newer one succeeded.

Order by x-event-time, or by event.timestamp in the body, and make your handler tolerant of an older event arriving after a newer one. A delivery held for a later time can arrive up to 30 days after its event, and a resent one later still.

An interested outcome your operator holds for its QA review can arrive up to five minutes after it was filed, and after events that happened later — see Leads held for QA. Its x-event-time is still when it was filed.

When an endpoint is switched off ​

  • 410 Gone — immediately, recorded as gone.
  • 20 consecutive failures — recorded as failures. Any single success resets the counter. The counter is per endpoint, so it measures "this URL is broken" rather than "this event is unlucky".
  • By hand, in the dashboard, recorded as manual.

Rule-directed Stop and evaluation errors leave this failure counter unchanged.

Disabling drops anything queued rather than holding it. Re-enabling resumes with new events only; what was missed meanwhile can be resent.

Deleting an endpoint stops deliveries for good. Its delivery log stays readable until it ages out.

The delivery log ​

Each endpoint has a log in the dashboard showing every delivery, its status (scheduled, pending, sending, delivered, given up, cancelled), its attempt count and the last status code we saw. A delivery held for a later time reads as Scheduled until that time. Deliveries can be retried or cancelled from there.

The log, and the events it was rendered from, are kept for 90 days; after that both are deleted, and an event that old can no longer be resent. WEBHOOKS.EVENT.RETENTION_SEC.

Open a delivery to see every attempt, newest first. Each records, for successes as well as failures:

  • When it started, whether it was scheduled or sent by hand (Retry or Send test), and the method and URL it was sent to — the endpoint may have been edited since.
  • Its HTTP status, duration and capture time, or the transport error when no response came back.
  • Up to 16 KiB of the response body, decoded as UTF-8 text. JSON is indented for reading; Copy gives the captured text. Binary bodies are omitted. Invalid UTF-8 and NUL characters are replaced, so this is diagnostic text, not a byte-for-byte archive.
  • Diagnostic headers only: cf-ray, content-length, content-type, date, location, request-id, retry-after, server, traceparent, x-correlation-id and x-request-id. Each value is capped at 512 bytes; cookies, authorization and other headers are not stored.

Diagnostic capture stops after one second or the remaining request deadline, whichever comes first. Truncated, interrupted, timed-out and omitted bodies are labelled explicitly. If a response rule requires the body, the same stream read may continue within the request deadline up to the separate 64 KiB evaluation limit, without expanding diagnostic storage. A truncated diagnostic snapshot is never treated as a complete body for classification. Snapshot capture itself does not change the verdict; configured response rules do.

Each attempt also shows its server address: the IP address it connected to, one of those your hostname resolved to at the time. It is recorded as soon as the connection opens, so a TLS failure still names the server that presented the certificate. It is empty when no connection opened.

A failure before any HTTP response records the transport error, named with its system code: for example Connection refused (ECONNREFUSED, 203.0.113.5:443) or TLS certificate has expired (CERT_HAS_EXPIRED). Attempts are numbered in order and the numbering carries on after a manual retry, even though the retry restarts the schedule. A number with nothing recorded was interrupted on our side before it finished. Deliveries made before attempt history was kept show only their latest attempt. The delivery list shows the latest attempt's status and a short 512-character error summary; our request payload is never stored in that error field.

Snapshots share the delivery's 90-day retention and webhook read permissions. They are not included in bulk delivery lists or telemetry. Response bodies can contain personal data or echoed credentials: do not return secrets in your body or diagnostic headers.

An empty log does not necessarily mean something is broken — an event filtered out by a subtype filter is never sent and writes no row.