Patterns — Designing Webhooks
Contents
- Delivery envelope
- Signing (producer) and verification (consumer)
- Retry schedule
- Consumer receiver checklist
- Dead-letter and redelivery API
- Gotchas
Delivery envelope
json
{
"delivery_id": "whd_01J8ZC4T9",
"type": "invoice.paid",
"created_at": "2026-08-05T14:30:00Z",
"api_version": "2026-06-01",
"data": { "invoice_id": "inv_42" }
}delivery_id is unique per event and STABLE across retries — it is the
consumer's dedup key. api_version pins the payload shape per subscriber.
Signing (producer) and verification (consumer)
Producer:
python
import hmac, hashlib, time
def sign(secret: str, body: bytes) -> str:
ts = str(int(time.time()))
msg = ts.encode() + b"." + body
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return f"t={ts},v1={sig}"
# Header: Webhook-Signature: t=1754404200,v1=5257a86...Consumer:
python
# 300 s = 5 min replay window: generous for clock skew, tight for replays.
MAX_SKEW = 300
def verify(secret, header, body) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, their_sig = parts["t"], parts["v1"]
if abs(time.time() - int(ts)) > MAX_SKEW:
return False
msg = ts.encode() + b"." + body
ours = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(ours, their_sig) # constant timeVerify against the RAW request body bytes — any re-serialization (JSON parse then dump) changes the bytes and fails the check.
Retry schedule
text
attempt delay cumulative
1 immediate 0
2 1 min 1 min
3 5 min 6 min
4 30 min 36 min
5 2 h ~2.6 h
6 12 h ~14.6 h → dead-letter after thisAdd ±20% jitter to every delay so a consumer recovering from an outage is not hit by a synchronized thundering herd. Timeout per attempt: 10 s.
Consumer receiver checklist (publish this in your docs)
text
1. Verify Webhook-Signature before parsing (raw body, constant-time compare).
2. Reject t older than 5 minutes.
3. Return 200 immediately; process async (queue). Slow handlers get retried
and you will process duplicates.
4. Deduplicate on delivery_id (store processed IDs ≥ 24 h).
5. Treat events as unordered; fetch current state from the API when it matters.
6. Do not whitelist our IPs as your only security — verify signatures.Dead-letter and redelivery API
text
GET /v1/webhook-deliveries?status=failed&endpoint_id=we_7
POST /v1/webhook-deliveries/{delivery_id}/redeliverKeep failed deliveries ≥ 30 days. Auto-disable endpoints failing every delivery for 7 consecutive days; email the owner at 24 h, 72 h, and on disable.
Gotchas
- Signing the parsed-then-reserialized body — key ordering and whitespace differ; always HMAC the raw bytes you send/receive.
- Redirects: don't follow 3xx on delivery (SSRF vector + signature is now going somewhere unverified). Treat as failure.
- SSRF on subscription: validate subscriber URLs (https only, no private IP ranges, resolve-and-check at send time too — DNS can change).
- Retrying 4xx forever: a 410 Gone should disable the endpoint immediately; a 401/403 after working deliveries usually means the consumer rotated secrets — alert, don't hammer.
- Ordering promises creep into docs via examples — audit docs so every example shows dedup + unordered handling.
- One shared secret for all endpoints of a customer — breach of one endpoint burns all; scope secrets per endpoint.