# 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 time ``` Verify against the RAW request body bytes — any re-serialization (JSON parse then dump) changes the bytes and fails the check. ## Retry schedule ``` 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 this ``` Add ±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) ``` 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 ``` GET /v1/webhook-deliveries?status=failed&endpoint_id=we_7 POST /v1/webhook-deliveries/{delivery_id}/redeliver ``` Keep 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.