Webhook Integration
Intro
Webhooks deliver at least once, in no particular order, over a
network that fails. A correct webhook integration verifies HMAC
signatures on the raw body, deduplicates by event ID, returns 2xx
immediately and processes asynchronously, and retries with
exponential backoff into a dead-letter queue.
Overview
Payload envelope
{
"id": "evt_a1b2c3d4",
"type": "order.shipped",
"created_at": "2026-03-22T10:30:00Z",
"data": { "order_id": "ord_xyz789", "carrier": "ups" }
}
- A unique event
id for idempotency.
- A dotted
type of the form resource.action
(order.shipped, invoice.payment_failed).
- ISO 8601
created_at for ordering and replay-window enforcement.
- Domain payload nested under
data so the envelope stays stable as
the domain model evolves.
Signature verification (HMAC-SHA256)
Senders compute an HMAC over the raw request body using a shared
secret and send it in a header (e.g. X-Webhook-Signature: sha256=<hex>).
Consumers recompute and compare in constant time:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received_signature):
return 401
Three things matter:
- Sign the raw body, not parsed JSON. Re-serializing reorders
keys and breaks the signature.
- Constant-time comparison (
hmac.compare_digest) — never
== — to avoid timing attacks.
- Replay protection. Include a timestamp in the signed payload
or as a separate signed header, and reject anything older than
~5 minutes.
Support multiple active secrets so you can rotate without downtime.
Idempotency
Webhook delivery is at-least-once. Consumers must dedupe:
- Store processed event IDs in a database or cache with a unique
constraint.
- Check before processing —
INSERT ... ON CONFLICT DO NOTHING is
the canonical pattern.
- TTL the idempotency store (e.g. 7 days) to bound storage. Senders
should not retry beyond that window.
Retry handling
As a sender: retry on transient failure with exponential backoff
(1 min, 5 min, 30 min, 2 hr, 8 hr, 24 hr is a typical schedule).
Treat 2xx as success, 5xx and timeouts as transient, and 4xx (except
429) as permanent failure. Include X-Webhook-Retry-Count so
consumers can log it.
As a consumer: verify the signature, enqueue the event for
asynchronous processing, and return 200/202 immediately. Never do
heavy work in the HTTP handler — the sender will time out and retry,
amplifying load.
Dead-letter queues
Events that exhaust the retry schedule move to a DLQ with full
context: payload, headers, last error, attempt count. Provide
operator tooling to inspect and replay. Alert when the DLQ grows
unexpectedly. Retain DLQ entries for at least 30 days so you can
recover from outages discovered after the fact.
Security
- TLS only. Never accept webhooks over plain HTTP.
- Always verify HMAC signatures, even if the source IP is
allowlisted.
- Restrict by sender IP ranges when the source publishes them
(Stripe, GitHub, etc.).
- Enforce a payload size limit (1 MB is typical) and rate-limit the
consumer endpoint.
- Store secrets in a vault (AWS Secrets Manager, Vault, etc.), not
in code or environment variables checked into source control.
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
- Verifying the HMAC signature against parsed and re-serialized JSON instead of the raw body. Re-serializing parsed JSON reorders keys, collapses whitespace, and changes the byte sequence — the computed HMAC will not match the sender's signature. Always compute the HMAC against the raw request body bytes, before any parsing.
- Non-constant-time signature comparison. Using
== to compare HMAC values allows a timing side-channel: an attacker can observe that the comparison returns faster when fewer bytes match, and iteratively guess the correct signature. Always use a constant-time comparison function such as hmac.compare_digest.
- No idempotency check — processing the same event twice. Webhook delivery is at-least-once. A network timeout or a 5xx response from your endpoint will cause the sender to retry, delivering the same event multiple times. A consumer that sends an email, charges a payment, or creates a database record without deduplicating by event ID will do so multiple times. Store processed event IDs and check before processing.
- Doing heavy processing in the HTTP handler instead of enqueuing. A handler that makes database queries, calls external APIs, or runs business logic before returning will frequently exceed the sender's request timeout (typically 5–30 seconds), causing the sender to retry — amplifying the work. Return 200 or 202 immediately after signature verification; enqueue the event payload for asynchronous processing.
- No replay-window check on the timestamp. A webhook signature protects the payload in transit but does not prevent replay attacks — a captured request can be replayed weeks later with a valid signature. Include a timestamp in the signed payload (or in a signed header), and reject events older than ~5 minutes to bound the replay window.
- Single signing secret with no rotation path. When a secret is leaked or an employee leaves, the secret must be rotated. If the consumer only accepts one active secret, rotation requires simultaneous updates to both the sender and consumer — a coordination window where webhooks either fail or are unverified. Support multiple active secrets so old events can still be verified during rotation.
- Returning 200 from a handler that errored internally. A handler that catches its own internal error and returns 200 tells the sender "delivery succeeded" — the sender will not retry, and the event is lost. Return 500 (after signature verification) so the sender retries. Reserve 4xx for cases where retrying will never succeed (malformed event, unsupported event type).
Full reference
Ordering
Webhooks do not guarantee delivery order. A consumer that assumes
"shipped" must arrive after "created" will eventually be wrong. Two
options:
- Use
created_at to detect out-of-order events and ignore the
stale one.
- Embed a monotonic version or sequence number on stateful
resources, then discard any event whose version is older than
what's already applied.
Both work; pick the one that fits the resource model.
Testing webhooks locally
- Tunnels:
ngrok and cloudflared tunnel expose a local
endpoint to a public URL so the sender can reach it.
- Inspection:
webhook.site and requestbin capture raw
requests for offline debugging when the sender doesn't expose a
test mode.
- Automated tests must cover: invalid signature (reject), valid
signature (accept), duplicate event ID (idempotent — process
once), out-of-order events, malformed JSON, oversized payload,
expired timestamp.
Failure modes worth designing for
| Symptom |
Likely cause |
| Events processed twice |
No idempotency check |
| Sender reports timeouts |
Synchronous heavy processing |
| Signature verification fails |
Parsed body re-serialized |
| Random 401s after secret rotation |
Old secret no longer accepted |
| State diverges from sender |
Out-of-order delivery ignored |
| DLQ grows unexpectedly |
Downstream system degraded |
The fix for the first two is the same on every platform: dedupe by
event ID with a unique constraint, and move processing to a
background queue so the HTTP handler returns in milliseconds.
Webhook registration API (when designing your own)
If you're publishing webhooks rather than consuming them, expose a
simple registration API:
POST /webhooks — register a url, an events[] filter, and
receive a generated signing secret in the response (shown once).
GET /webhooks/{id}/deliveries — recent delivery attempts with
status, latency, and response body for debugging.
POST /webhooks/{id}/deliveries/{delivery_id}/replay — manual
replay for ops recovery.
- Admin dashboard with delivery success rate, latency percentiles,
and DLQ depth per subscription.
Anti-patterns to avoid
- Synchronous processing in the HTTP handler. Always enqueue and
return immediately.
- Verifying the signature against parsed JSON. Sign and verify
the raw bytes the sender produced.
== for signature comparison. Use a constant-time function.
- No idempotency store. "It probably won't happen twice" is
wrong on the day it matters.
- Returning 200 from a handler that errored internally. The
sender will not retry. Return 500 so retry happens, after the
signature has been verified.
- No replay-window check. A leaked recording can be replayed
weeks later.
- Single signing secret with no rotation path. Plan rotation
before you need it.
1---2name: webhook-integration3description: Webhook design and consumption — payload format, HMAC signatures, idempotency, retries, dead-letter queues, security. Use when implementing a webhook consumer, designing an event-notification system, adding signature verification, or debugging duplicate or failed webhook deliveries.4---56# Webhook Integration78## Intro910Webhooks deliver at least once, in no particular order, over a11network that fails. A correct webhook integration verifies HMAC12signatures on the raw body, deduplicates by event ID, returns 2xx13immediately and processes asynchronously, and retries with14exponential backoff into a dead-letter queue.1516## Overview1718### Payload envelope1920```json21{22 "id": "evt_a1b2c3d4",23 "type": "order.shipped",24 "created_at": "2026-03-22T10:30:00Z",25 "data": { "order_id": "ord_xyz789", "carrier": "ups" }26}27```2829- A unique event `id` for idempotency.30- A dotted `type` of the form `resource.action`31 (`order.shipped`, `invoice.payment_failed`).32- ISO 8601 `created_at` for ordering and replay-window enforcement.33- Domain payload nested under `data` so the envelope stays stable as34 the domain model evolves.3536### Signature verification (HMAC-SHA256)3738Senders compute an HMAC over the **raw request body** using a shared39secret and send it in a header (e.g. `X-Webhook-Signature: sha256=<hex>`).40Consumers recompute and compare in constant time:4142```python43expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()44if not hmac.compare_digest(expected, received_signature):45 return 40146```4748Three things matter:49501. **Sign the raw body**, not parsed JSON. Re-serializing reorders51 keys and breaks the signature.522. **Constant-time comparison** (`hmac.compare_digest`) — never53 `==` — to avoid timing attacks.543. **Replay protection.** Include a timestamp in the signed payload55 or as a separate signed header, and reject anything older than56 ~5 minutes.5758Support multiple active secrets so you can rotate without downtime.5960### Idempotency6162Webhook delivery is at-least-once. Consumers must dedupe:6364- Store processed event IDs in a database or cache with a unique65 constraint.66- Check before processing — `INSERT ... ON CONFLICT DO NOTHING` is67 the canonical pattern.68- TTL the idempotency store (e.g. 7 days) to bound storage. Senders69 should not retry beyond that window.7071### Retry handling7273**As a sender:** retry on transient failure with exponential backoff74(1 min, 5 min, 30 min, 2 hr, 8 hr, 24 hr is a typical schedule).75Treat 2xx as success, 5xx and timeouts as transient, and 4xx (except76429) as permanent failure. Include `X-Webhook-Retry-Count` so77consumers can log it.7879**As a consumer:** verify the signature, enqueue the event for80asynchronous processing, and return 200/202 immediately. Never do81heavy work in the HTTP handler — the sender will time out and retry,82amplifying load.8384### Dead-letter queues8586Events that exhaust the retry schedule move to a DLQ with full87context: payload, headers, last error, attempt count. Provide88operator tooling to inspect and replay. Alert when the DLQ grows89unexpectedly. Retain DLQ entries for at least 30 days so you can90recover from outages discovered after the fact.9192### Security9394- TLS only. Never accept webhooks over plain HTTP.95- Always verify HMAC signatures, even if the source IP is96 allowlisted.97- Restrict by sender IP ranges when the source publishes them98 (Stripe, GitHub, etc.).99- Enforce a payload size limit (1 MB is typical) and rate-limit the100 consumer endpoint.101- Store secrets in a vault (AWS Secrets Manager, Vault, etc.), not102 in code or environment variables checked into source control.103104## Gotchas105106Agent-specific failure modes — provider-neutral pause-and-self-check items:107108- **Verifying the HMAC signature against parsed and re-serialized JSON instead of the raw body.** Re-serializing parsed JSON reorders keys, collapses whitespace, and changes the byte sequence — the computed HMAC will not match the sender's signature. Always compute the HMAC against the raw request body bytes, before any parsing.109- **Non-constant-time signature comparison.** Using `==` to compare HMAC values allows a timing side-channel: an attacker can observe that the comparison returns faster when fewer bytes match, and iteratively guess the correct signature. Always use a constant-time comparison function such as `hmac.compare_digest`.110- **No idempotency check — processing the same event twice.** Webhook delivery is at-least-once. A network timeout or a 5xx response from your endpoint will cause the sender to retry, delivering the same event multiple times. A consumer that sends an email, charges a payment, or creates a database record without deduplicating by event ID will do so multiple times. Store processed event IDs and check before processing.111- **Doing heavy processing in the HTTP handler instead of enqueuing.** A handler that makes database queries, calls external APIs, or runs business logic before returning will frequently exceed the sender's request timeout (typically 5–30 seconds), causing the sender to retry — amplifying the work. Return 200 or 202 immediately after signature verification; enqueue the event payload for asynchronous processing.112- **No replay-window check on the timestamp.** A webhook signature protects the payload in transit but does not prevent replay attacks — a captured request can be replayed weeks later with a valid signature. Include a timestamp in the signed payload (or in a signed header), and reject events older than ~5 minutes to bound the replay window.113- **Single signing secret with no rotation path.** When a secret is leaked or an employee leaves, the secret must be rotated. If the consumer only accepts one active secret, rotation requires simultaneous updates to both the sender and consumer — a coordination window where webhooks either fail or are unverified. Support multiple active secrets so old events can still be verified during rotation.114- **Returning 200 from a handler that errored internally.** A handler that catches its own internal error and returns 200 tells the sender "delivery succeeded" — the sender will not retry, and the event is lost. Return 500 (after signature verification) so the sender retries. Reserve 4xx for cases where retrying will never succeed (malformed event, unsupported event type).115116## Full reference117118### Ordering119120Webhooks do **not** guarantee delivery order. A consumer that assumes121"shipped" must arrive after "created" will eventually be wrong. Two122options:1231241. **Use `created_at`** to detect out-of-order events and ignore the125 stale one.1262. **Embed a monotonic version or sequence number** on stateful127 resources, then discard any event whose version is older than128 what's already applied.129130Both work; pick the one that fits the resource model.131132### Testing webhooks locally133134- **Tunnels:** `ngrok` and `cloudflared tunnel` expose a local135 endpoint to a public URL so the sender can reach it.136- **Inspection:** `webhook.site` and `requestbin` capture raw137 requests for offline debugging when the sender doesn't expose a138 test mode.139- **Automated tests** must cover: invalid signature (reject), valid140 signature (accept), duplicate event ID (idempotent — process141 once), out-of-order events, malformed JSON, oversized payload,142 expired timestamp.143144### Failure modes worth designing for145146| Symptom | Likely cause |147|------------------------------------|--------------------------------|148| Events processed twice | No idempotency check |149| Sender reports timeouts | Synchronous heavy processing |150| Signature verification fails | Parsed body re-serialized |151| Random 401s after secret rotation | Old secret no longer accepted |152| State diverges from sender | Out-of-order delivery ignored |153| DLQ grows unexpectedly | Downstream system degraded |154155The fix for the first two is the same on every platform: dedupe by156event ID with a unique constraint, and move processing to a157background queue so the HTTP handler returns in milliseconds.158159### Webhook registration API (when designing your own)160161If you're publishing webhooks rather than consuming them, expose a162simple registration API:163164- `POST /webhooks` — register a `url`, an `events[]` filter, and165 receive a generated signing secret in the response (shown once).166- `GET /webhooks/{id}/deliveries` — recent delivery attempts with167 status, latency, and response body for debugging.168- `POST /webhooks/{id}/deliveries/{delivery_id}/replay` — manual169 replay for ops recovery.170- Admin dashboard with delivery success rate, latency percentiles,171 and DLQ depth per subscription.172173### Anti-patterns to avoid174175- **Synchronous processing in the HTTP handler.** Always enqueue and176 return immediately.177- **Verifying the signature against parsed JSON.** Sign and verify178 the raw bytes the sender produced.179- **`==` for signature comparison.** Use a constant-time function.180- **No idempotency store.** "It probably won't happen twice" is181 wrong on the day it matters.182- **Returning 200 from a handler that errored internally.** The183 sender will not retry. Return 500 so retry happens, after the184 signature has been verified.185- **No replay-window check.** A leaked recording can be replayed186 weeks later.187- **Single signing secret with no rotation path.** Plan rotation188 before you need it.