-
Sign every payload — HMAC-SHA256 over the exact bytes you send, with a per-endpoint secret. Compute the signature over "{timestamp}.{raw_body}" (the same bytes on the wire — serialize ONCE, sign that buffer, send that buffer; never re-serialize between signing and sending or receivers' verification breaks). Put it in a versioned header so you can add schemes later without breaking verifiers:
X-Webhook-Id: evt_01HZ... # stable unique event id (also a dedup key for the consumer)
X-Webhook-Timestamp: 1718409600 # unix seconds; part of the signed preimage
X-Webhook-Signature: t=1718409600,v1=5257a8... # v1 = hex HMAC-SHA256(secret, "{t}.{body}")
import hmac, hashlib, json
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
t = str(int(time.time()))
sigs = [f"v1={hmac.new(s, f'{t}.'.encode()+raw, hashlib.sha256).hexdigest()}"
for s in active_secrets_for(endpoint)]
headers = {"X-Webhook-Id": event_id, "X-Webhook-Timestamp": t,
"X-Webhook-Signature": "t=" + t + "," + ",".join(sigs)}
One secret per endpoint (never a global secret — a leak then compromises every customer). Document the verify recipe for receivers and point them at ingest-webhook-secure.
-
Support secret rotation with an overlap window — send multiple signatures. Rotation = generate a new secret, mark both old+new active, and sign with both during the overlap (v1=<old>,v1=<new>). The receiver accepts a request if any signature matches, so they can swap secrets at their leisure. After the documented overlap (e.g. 24–72 h, or when the customer confirms), retire the old secret. Without overlap, rotation drops every in-flight delivery. Store secrets via secrets-management; show "rotate" + "reveal once" in the dashboard.
-
Include a signed timestamp + document a tolerance window so receivers can reject replays. The t you sign lets a receiver drop a captured-and-replayed request that's older than their tolerance (recommend ±300 s). You can't enforce it — but you must (a) put t inside the signed preimage (not just a loose header), (b) keep your senders' clocks NTP-synced so legit deliveries land inside the window, and (c) document the recommended tolerance so receivers implement it. Drift on your side = false replay rejections at every customer.
-
Deliver at-least-once with exponential backoff + jitter over hours/days, then dead-letter. Treat any non-2xx, timeout, or connection error as retryable; 2xx (any) = delivered, stop. Build the schedule on resilience-timeouts-retries (full jitter, per-attempt timeout ~10 s). Give up after N attempts (e.g. 8–15) spread across a long horizon, then move to a dead-letter store with a manual replay button.
| Attempt | Delay (base, +jitter) | Cumulative |
|---|
| 1 | immediate | 0 |
| 2 | ~30 s | ~30 s |
| 3 | ~2 m | ~3 m |
| 4 | ~10 m | ~13 m |
| 5 | ~1 h | ~1 h |
| 6 | ~3 h | ~4 h |
| 7–N | ~6 h, capped | up to ~1–3 days |
After exhaustion → DLQ row with last status/error; surface it in the dashboard with a one-click "replay" that re-enqueues the same event (same id + sequence) so consumer dedup still works. Auto-disable an endpoint that's failed for days and email the owner.
-
Send a STABLE unique event id and a per-endpoint SEQUENCE number — you WILL re-deliver and you do NOT guarantee order. The event_id is generated once at event creation and is identical across every retry of that event (it's the consumer's dedup key → idempotency-keys). Add a monotonic sequence (per endpoint or per resource) and the timestamp so consumers can detect/repair reordering. Explicitly document: delivery is at-least-once and unordered — retries and parallel sends mean updated can arrive before created; dedup on event_id, order on sequence/timestamp, never on arrival order. Don't pretend ordering you can't deliver.
-
Send THIN events; let the consumer fetch the full resource. Payload = { id, type, timestamp, sequence, data: { id, <a few key fields> } } — enough to route and decide, not the whole object. Then the consumer GETs /v1/orders/{id} from your API for current truth. This avoids (a) stale payloads (the resource changed between enqueue and delivery), (b) oversized bodies that blow timeouts, and (c) leaking fields the subscriber shouldn't see / that bloat your audit logs. For events that are inherently terminal facts (invoice.finalized snapshot), a fuller payload is fine — but default thin.
-
Version the event schema and keep it stable. Give every event a type (order.created) and an explicit schema version ("api_version": "2026-06-01" on the event, or v1 in the signature header). Add fields, never repurpose/remove within a version; breaking changes = a new version that customers opt into. Publish a typed catalog of event types + JSON schema. Stripe-style dated versions or a coarse v1/v2 both work — pick one and document it.
-
Verify the endpoint on registration before sending real traffic. When a customer adds a URL, prove they control it and it works: send a test/ping event (or a challenge-response where they echo a token) and require a 2xx within timeout before marking the endpoint active. This blocks typos, dead URLs, and registering someone else's endpoint to flood it. Re-verify on URL change. Keep the endpoint in pending until the test succeeds.
-
Lock the target down against SSRF — your dispatcher is a server-side request to a customer-controlled URL. This is the highest-severity bug in any webhook sender. On registration AND on every send (DNS rebinding defeats register-time-only checks):
- HTTPS only. Reject
http://, file://, gopher://, etc.
- Resolve the hostname yourself, then block loopback/private/link-local/metadata ranges:
127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (incl. cloud metadata 169.254.169.254), ::1, fc00::/7, fe80::/10, 0.0.0.0. Block by resolved IP, not by string-matching the hostname.
- Pin the connection to the IP you validated (connect to the checked IP / re-resolve and re-check just before connect) so a TOCTOU re-resolution can't swap in an internal IP between check and connect.
- Disable redirect following (or re-validate every hop) — a 302 to
http://169.254.169.254 bypasses a register-time check.
- Egress from a locked-down network (no access to internal services / metadata endpoint) as defense in depth. Cap response body read and per-attempt timeout. See remediate-web-vulnerabilities for the SSRF class.
-
Isolate delivery per endpoint/customer so one bad target can't stall the rest. Run delivery on message-queue-jobs, but partition: a per-endpoint queue/lane with bounded concurrency, so a customer whose endpoint hangs/5xx's only backs up their lane. Add a per-endpoint rate cap (→ rate-limiting) to respect receivers' limits, and a circuit breaker (→ resilience-timeouts-retries) that fast-fails (straight to the retry schedule) while an endpoint is consistently down instead of burning worker time on it. Never deliver all customers off one shared FIFO — head-of-line blocking will take down delivery for everyone the moment one endpoint goes slow.
-
Observability — per-attempt logs + a customer-facing delivery history and replay UI. Log every attempt (not just final): event id, endpoint, attempt #, response status, latency, error, next-retry-at. Emit metrics per endpoint: success rate, attempt count, p50/p95 latency, dead-letter count (→ observability-instrument). Expose to the customer a delivery history showing each event, its attempts, response codes/bodies (truncated), and a manual replay button — this is what turns "your webhooks are broken" support tickets into self-service. Alert the owner when an endpoint's success rate craters or it gets auto-disabled.
Done = each event is HMAC-signed per-endpoint over the exact bytes with a signed timestamp and rotation overlap, delivery is at-least-once with jittered backoff → DLQ → manual replay, events are thin and carry a stable id + sequence so consumers dedup and reorder, the target URL is HTTPS-only and SSRF-blocked (private/link-local/metadata ranges, re-checked on every send), endpoints are verified before activation, delivery is isolated per endpoint, and every attempt is logged and visible to the customer with a working replay — all proven by checks 1–11.