Module P-17·34 min read

The Stripe model from both sides — signing and replay protection, retry schedules with backoff, receiver-side queue-then-ack, and fan-out to thousands of endpoints.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-17 — Webhooks and Callback Architectures

What this module covers: A webhook is a request you make into someone else's infrastructure, and a request you accept from someone else's — so this module covers both sides. As sender: HMAC signing over the raw body with a timestamp, secret rotation with overlapping keys, retry schedules with a bounded window, per-endpoint circuit breaking so one dead customer can't consume your delivery capacity, the ordering you must refuse to promise, and fan-out to thousands of endpoints as a partitioning problem. As receiver: verify first, then queue-then-ack, idempotent handling, out-of-order arrival, the polling trade-off, reconciliation as the required backstop, and why a user-supplied callback URL is an SSRF primitive.


Both Sides of a Webhook Fail Differently, So Learn Both

Most teams meet webhooks twice. Once as a consumer — Stripe or GitHub or a shipping carrier posts to your endpoint and you have to survive it. Once as a producer — your customers want to know when a job finishes, and now you own an outbound delivery system that talks to hundreds of endpoints you don't control, can't test, and can't fix.

The two roles fail in mirror-image ways, and the mirror is the useful part:

Sender's failureReceiver's failure
LatencyOne slow endpoint occupies delivery workers and delays everyone else's eventsInline processing makes your latency the sender's timeout, so they retry
DuplicatesYou must resend on ambiguous failure, so you send duplicates by designYou must deduplicate, because redelivery is normal, not exceptional
OrderingYou cannot guarantee it across retries and parallel workersYou must not assume it; arrival order is not event order
TrustYour event must be provably from youThe payload is attacker-controlled until the signature verifies
GapsSome events will never be delivered, whatever you doYou need a reconciliation path, because "never delivered" is silent

Every row of that table is one of the sections below. The structural point is that a webhook is an unreliable, unordered, at-least-once channel between two systems with different availability targets, and both ends have to be designed for that rather than for the happy path where the POST returns 200 in 40 ms.

Analogy: a webhook is a courier delivering a signed letter to an address the recipient gave you months ago. The courier retries if nobody answers the door, so the recipient may get two copies. Letters posted on different days can arrive in either order because they took different vans. The address may now be a building site, and the recipient must check the seal before believing a word inside — because anyone can put an envelope through a letterbox.


Sign the Raw Bytes, Because Re-Serialised JSON Is a Different Document

An unsigned webhook endpoint is a public API for injecting events into your customer's system. Anyone who learns the URL can post {"event":"invoice.paid"} and see what happens. So every event carries an HMAC signature computed with a shared secret.

Two properties matter, and the second is the one that gets forgotten.

The signature must cover a timestamp, or it is a replay token. A signature over the body alone is valid forever. Anyone who captures one delivery — a proxy log, a misconfigured APM tool that records request bodies, a former employee's saved cURL — can replay it indefinitely, and the receiver's verification will pass, because it is your signature over your payload. Signing timestamp + "." + body and having the receiver reject anything outside a tolerance window (five minutes is the usual choice) bounds the replay to that window.

The signature must be computed over the exact bytes on the wire. This is the single most common integration bug in webhooks, on both sides.

ts

The receiver has to verify against those same bytes, which means capturing the raw body before any JSON middleware touches it:

ts

Why re-serialising breaks it: JSON.parse then JSON.stringify is not the identity function on bytes. Key order can survive in V8 for string keys but not for integer-like keys, which get reordered numerically. Whitespace and indentation vanish. Non-ASCII characters may have arrived as é escapes and come back as raw UTF-8, or the reverse. Numbers get renormalised — 1.0 becomes 1, 1e3 becomes 1000, and an integer beyond 2^53 loses precision permanently. Duplicate keys collapse. None of this is a bug in the JSON library; it is the point of parsing, which is to discard representation and keep meaning. A MAC is over representation.

The production symptom is diagnostically nasty: signatures verify for most events and fail for a few. The failures correlate with payload content — a customer whose name contains an accent, an order whose ID crossed a numeric threshold, an event type whose payload happens to include a float. Teams chase clock skew and header casing for days because the failure isn't uniform.

Why this matters in production: the failure mode of a signature check you got subtly wrong is not "insecure", it is intermittent and content-dependent, which means it survives your entire test suite and appears only against real customer data. Verifying raw bytes is cheap. Reconstructing which of ten thousand missed events mattered, after you discover the bug three weeks later, is not.

One more sender-side detail: sign with sha256 HMAC, not a bare hash of secret + body, and never rely on TLS alone. TLS proves the transport wasn't tampered with; it says nothing about whether the sender is you, which is exactly the claim the receiver needs.


Rotate Secrets With Overlapping Keys, Never a Flag Day

A signing secret leaks: it goes into a customer's client-side bundle, or a support ticket, or a public repository. You need to rotate it. The naive rotation is an atomic swap — you change the secret, the customer changes theirs — and it fails for three reasons. Deployments are not simultaneous. In-flight retries were signed with the old secret. And you cannot coordinate a synchronous config change with a customer whose release process you don't control.

The design is overlapping validity: an endpoint has a set of active secrets, and you sign with all of them.

ts

The receiver iterates its known secrets and accepts on the first constant-time match. Rotation becomes: add the new secret (both now sent) → customer deploys the new secret whenever they like → after a defined overlap, remove the old one. The cost is a window during which a leaked secret is still valid, so the overlap should be as short as your slowest customer's deploy cycle allows, and immediate revocation must remain possible for an actual compromise — accepting that it will break receivers who haven't rotated. That is the trade: convenience of rotation versus the exposure window, and a compromise is the case where you choose breakage.

The receiver side of the same design is a list rather than a single env var, which is worth building before you need it:

ts

A receiver with a single-valued secret config forces every sender rotation into a coordinated outage.


Retries Are a Schedule With an End, Not a Loop

Delivery fails constantly and mostly harmlessly: the receiver deployed, their load balancer drained a node (Module F-11), their database was briefly slow. So you retry. The question is the schedule, and the constraint is that your retries are load on someone who may already be overloaded.

Fixed-interval retries are the wrong answer for the reason Module A-6 develops: a thousand senders retrying every 30 seconds against a recovering endpoint is a synchronised thundering herd that prevents recovery. Exponential backoff with jitter spreads the pressure, and a bounded total window stops you from carrying an event forever.

AttemptDelay (approx.)Cumulative
1immediate0
210 s10 s
31 min~1 min
45 min~6 min
530 min~36 min
62 h~3 h
76 h~9 h
812 h~21 h
924 h~2 days

Nine attempts over roughly two days, each delay multiplied by random jitter in the 0.5–1.5 range. Then the event goes to a dead-letter store and the endpoint is flagged. The properties that matter:

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.