webhook verification

why

a webhook endpoint accepts input from the open internet; anyone who knows the url can post a forged payload. durable writes on unverified input are attacker-authored, and dedup guarantees vanish once replays are indistinguishable from real events. verification — signature, timestamp window, event identity — before any effect is the only cheap point to prove "this really came from the provider."

flowchart TD
W[inbound webhook] --> S{signature verifies?}
S -->|no| R1[typed rejection]
S -->|yes| T{timestamp in window?}
T -->|no| R1
T -->|yes| P{schema validates?}
P -->|no| R1
P -->|yes| D{event id seen?}
D -->|yes| X[2xx · no re-run]
D -->|no| Q[record durable fact]
Q --> H[hand off to workflow/step]

rule

an inbound webhook is an untrusted boundary input. verify the signature with the provider's scheme, check the timestamp window, deduplicate by the provider's event identity — all before any durable write or domain behavior.

intake order

  1. verify the signature against the raw request body, with the provider's exact scheme (HMAC over specific headers, or signed-exchange token). typed rejection before parsing anything else.
  2. check the timestamp window to reject stale replays.
  3. parse and validate with a named schema, per zod boundaries.
  4. deduplicate on the provider's event id: a repeated delivery finds the recorded event and returns success without re-running effects.
  5. record the durable fact, then hand off, per durable handoffs.

the handler is a thin intake boundary in the pattern of cloudflare stack: verify, translate to a typed domain event, delegate. no business policy in it.

delivery semantics

senders retry on any non-2xx and often redeliver on timeout even after success. the operation must be idempotent: same event id delivered three times produces one durable effect. respond 2xx only after the event is durably recorded — a 2xx before the write loses the retry.

do not

  • parse the payload body before the signature verifies.
  • trust header claims (event type, account) the signature does not cover; bind claims to the verified payload.
  • process by best-effort matching on payload fields when a stable event id exists.
  • answer success before the event is durably recorded.
  • verify with a different canonicalization than the provider documents (header order, encoding, trailing newline).

verification

  • replay one captured real delivery: same signature, same event id — durable effect count does not increase.
  • tamper with the body or a signature header — typed rejection before parsing.
  • deliver a stale timestamp — rejection.
  • send the same event under a new event id (provider redelivery) — processing runs with idempotent side effects.

good and bad

bad

const payload = JSON.parse(rawBody); // parsed before verification
if (payload.type === "charge.succeeded") {
// unverified claim drives behavior
await applyCharge(payload); // no dedupe, 2xx sent immediately
}

unverified replayable delivery runs domain effects on every redelivery; claims the signature does not cover decide what happens. anyone who learns the endpoint url can forge durable records; retries multiply the forged effect, and nothing in the store distinguishes real from attacker posts. recovery means manual data surgery on facts you no longer trust.

good

if (!verifySignature(rawBody, headers)) return rejectDiagnostic();
if (!inWindow(headers.timestamp)) return rejectDiagnostic();
const event = schema.parse(rawBody);
if (seen(event.id)) return ok(); // idempotent replay
await recordEvent(event.id, event); // 2xx only after the durable write

signature, timestamp window, schema, and event-id dedup all pass before any durable effect — redelivery is a no-op success. the durable core only holds provider-authored facts; retries stay cheap and safe, and the recorded event id traces every stored effect to one verified delivery.

search pages

go to any page