← Architecture
Events, queues, and workflows Someone else’s events

Receiving webhooks

Verify the bytes. Record once. Answer fast.

When a payment provider tells your app that an invoice was paid, it does it by calling a URL you gave it. That call is the only source of truth you get, it may come twice or late, and the URL is public. Taking it well is four small decisions made in the right order.

TypeScriptGoOne endpoint, three receivers, real signatures, two recorded builds.

01 / The prompt

“When the provider says an invoice is paid, mark the subscription active.”

A small newsletter platform with paid subscriptions. The provider handles the card; the platform learns what happened from webhooks: invoice.paid, customer.subscription.deleted. The obvious handler parses the JSON and updates the subscription. It passes every test that sends one well-formed event.

The provider does not send one well-formed event. It retries whatever did not get a 2xx, so the same payment arrives more than once; in the lesson’s example, three deliveries of one 900-cent payment count 2700 cents of revenue. It does not promise order, so a cancellation can arrive before the payment it follows. And its bytes are its own. Stripe’s guide warns that frameworks that change the body, “adding or removing whitespace, reordering the key-value pairs, converting the string to JSON, or changing the encoding”, break verification (Stripe, webhook signature errors).

The brief never asked the question: which deliveries do we believe, what do we do with the second copy, and what do we do before we answer?

02 / Name the shape

Verify the bytes, record the event, answer, then do the work.

A webhook is another company’s system calling yours with an event. Receiving one well is a sequence with a reason for each step: check the signature over the exact bytes that arrived, and its age, because the URL is public; record the event under its own id, because it will come again; answer quickly, because a slow answer is a failed one and will be retried; then apply it, in the order the events happened, not the order they arrived.

Nothing in the body is trusted until the signature over the raw bytes checks out. Each event id is recorded once, and applied once, by its created time.

Who owns what:

What each side of the webhook owns
PartOwnsPromises
The providerWhat happened, the event id, and whenA signature over its bytes; retries until a 2xx; no order
The doorWhether to believe a deliveryA 401 for a bad or old signature; a 2xx only after recording
The receipt tableWhich events arrived, once eachA second copy changes nothing
The workerSubscriptions and revenueApplies each event once, oldest first, never over a newer one

Words to put in a prompt or a review

Signature
An HMAC of the timestamp and the raw body, keyed with a secret only you and the provider hold.
Raw body
The bytes as they arrived. Parsed-then-stringified JSON is a different string.
Tolerance
How old a signed timestamp may be. It is what makes a captured request useless later.
Receipt
An event recorded under its provider id before the reply, so a retry is recognized.
Acknowledge
The 2xx that stops the provider’s retries. It should mean “recorded”, not “done”.
Created time
When the event happened at the provider, which is the order to apply it in.
Why not just answer after doing the work?The provider’s timeout

The provider waits a few seconds for an answer. Work that sends an email or calls another API inside the request can run past that, and the provider retries a delivery your code has already half done. Recording and answering, then working, keeps the answer fast and makes the work a job; Work queues and background jobs covers running it reliably.

03 / Twice, late, and forged

Same deliveries, different receivers. Which ones match what really happened?

Each column runs one of the lesson’s receivers on the same deliveries, with a real HMAC over every body, and is audited against the events the provider actually created. Watch four situations, then open Try it and send the deliveries yourself.

Receiving webhooks

Deliveries that arrive twice, late, and from strangers

Parse it and act on it

Last reply: 200

Receipts
0
Revenue
900 cents · 1 invoice(s)
sub_ada
active

Verify raw bytes, record, then work

Last reply: 202

Receipts
1
Revenue
0 cents · 0 invoice(s)
Subscriptions
none yet
01/ 04
The provider retries

The provider delivers a payment of 900 cents (evt_1).

The same payment arrives three times, as it does when a reply is slow or lost. Acting on every delivery counts 900 cents three times. Recording by event id counts it once.

Reduced motion: choose a scene to see its completed state.

Read this scene

The same payment arrives three times, as it does when a reply is slow or lost. Acting on every delivery counts 900 cents three times. Recording by event id counts it once.

Parse it and act on it: last reply 200, revenue 900 cents, sub_ada active.

Verify raw bytes, record, then work: last reply 202, revenue 0 cents, no subscriptions.

Watch restarts the story when you come back. Step through shows where each chapter ends. Try it starts a new platform whenever you change the receiver or press Reset.

04 / Read the shape

A door that checks bytes, a worker that orders events, and a handler that keeps the body raw.

Basic form is the receiver’s door. In the wild is the worker. At the call site is the HTTP handler. Notice where the JSON parser is called.

The receiver’s door: the signature and its age are checked over the raw bytes before anything is parsed, then the event is recorded once by its id and the provider gets its answer.

TypeScriptReading
platform.ts
/**
 * The door. Check the signature over the raw bytes, and its age, before
 * trusting anything in them. Then record the event by its id, once, and
 * answer. Nothing else happens in the request.
 */
receive(raw: string, header: string): number {
	const signature = this.parseSignature(header);
	if (!signature || Math.abs(this.now - signature.t) > tolerance) return 401;
	if (!sameHex(hmacSha256(this.secret, `${signature.t}.${raw}`), signature.v1)) return 401;
	let event: Event;
	try {
		event = JSON.parse(raw) as Event;
	} catch {
		return 400;
	}
	if (!this.receipts.some((r) => r.id === event.id))
		this.receipts.push({ ...event, applied: false });
	return 202;
}
GoAlongside
platform.go
// Receive is the door. Check the signature over the raw bytes, and its age,
// before trusting anything in them. Then record the event by its id, once, and
// answer. Nothing else happens in the request.
func (p *Receiver) Receive(raw, header string) int {
	t, v1, ok := parseSignature(header)
	if !ok || abs(p.Now-t) > tolerance {
		return 401
	}
	if !hmac.Equal([]byte(sign(p.secret, t, raw)), []byte(v1)) {
		return 401
	}
	var e Event
	if json.Unmarshal([]byte(raw), &e) != nil {
		return 400
	}
	if !p.recorded(e.ID) {
		p.Receipts = append(p.Receipts, &Receipt{e, false})
	}
	return 202
}
The receiver that trusts the bodyParse it and act on it
platform.ts
/** Believes the body: parse it and act on it, now. */
export class TrustingPlatform extends Platform {
	receive(raw: string): number {
		let event: Event;
		try {
			event = JSON.parse(raw) as Event;
		} catch {
			return 400;
		}
		this.apply(event, false);
		return 200;
	}
}
The receiver that checks the wrong stringA signature over re-serialized JSON

It has a signature check, deduplication, and ordering. It signs JSON.stringify(event) instead of the body, so it works exactly as long as the provider sends compact JSON with keys in the same order.

platform.ts
/** Checks a signature, but over the JSON it re-serialized, not the bytes that arrived. */
export class ReparsedPlatform extends Platform {
	receive(raw: string, header: string): number {
		const signature = this.parseSignature(header);
		if (!signature) return 401;
		let event: Event;
		try {
			event = JSON.parse(raw) as Event;
		} catch {
			return 400;
		}
		const expected = hmacSha256(this.secret, `${signature.t}.${JSON.stringify(event)}`);
		if (!sameHex(expected, signature.v1)) return 401;
		if (this.receipts.some((r) => r.id === event.id)) return 200;
		this.receipts.push({ ...event, applied: true });
		this.apply(event, true);
		return 200;
	}
}
The behavior these examples promiseChecked by 21 shared scenarios
  • Trusting the body: every copy counts, arrival order wins, and a forgery is applied like a real payment.
  • Checking re-serialized JSON: correct for compact bodies, and a genuine pretty-printed delivery is refused.
  • The receiver: a forgery, a tampered body, and a delivery signed ten minutes earlier get 401; every genuine event is recorded once and, after the worker runs, applied once in created order.

Every expectation was generated by a separate model written from the contract in the examples’ README, not copied from either implementation, and it is kept beside the examples.

Reading the TypeScriptA portable HMAC

The receivers use hmacSha256 from hmac.ts, a small SHA-256 in plain TypeScript, so the same code runs in the browser lab. On a server, use createHmac from node:crypto; the lesson’s test checks the two agree on two hundred random inputs. sameHex compares without stopping at the first difference.

Reading the Gocrypto/hmac, and hmac.Equal

Go uses crypto/hmac and compares with hmac.Equal, which takes the same time whatever the input. The handler reads the body with io.ReadAll behind a MaxBytesReader, and a ticker runs the worker; its test posts a pretty-printed body and a forged header through httptest.

Run it yourselfNo dependencies

Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later), or go run . in the Go folder. Both print:

trusting · delivered three times: 200 200 200, active, revenue 2700 -> revenue-off:1800
reparsed · delivered three times: 200 200 200, active, revenue 900 -> matches the provider
receiver · delivered three times: 202 202 202, active, revenue 900 -> matches the provider
trusting · cancel arrives first: 200 200, active, revenue 900 -> subscription-wrong:sub_ada
reparsed · cancel arrives first: 200 200, canceled, revenue 900 -> matches the provider
receiver · cancel arrives first: 202 202, canceled, revenue 900 -> matches the provider
trusting · forged payment: 200, active, revenue 100000 -> forged-accepted:evt_9, revenue-off:100000, subscription-wrong:sub_ada
reparsed · forged payment: 401, none, revenue 0 -> matches the provider
receiver · forged payment: 401, none, revenue 0 -> matches the provider
trusting · pretty-printed body: 200, active, revenue 900 -> matches the provider
reparsed · pretty-printed body: 401, none, revenue 0 -> revenue-off:-900, subscription-wrong:sub_ada
receiver · pretty-printed body: 202, active, revenue 900 -> matches the provider

05 / Review the agent’s diff

“I use the parsed body for the signature check now.”

It reads the body once instead of twice. Read what it now signs.

The agent’s pull request

“The handler read the body twice, once as text and once as JSON. I use the parsed body for the signature check now, so we only read it once. All webhook tests pass.”

// routes/webhooks/payments.ts
			(removed) const raw = await request.text();
			(removed) if (!verifySignature(raw, request.headers.get("payment-signature"))) {
			(added) const event = await request.json(); // the framework already parsed it
			(added) if (!verifySignature(JSON.stringify(event), request.headers.get("payment-signature"))) {
			  return new Response("bad signature", { status: 401 });
			}
			(removed) const event = JSON.parse(raw);
			await receipts.record(event);
			
The tests send compact JSON from a fixture. What do you do with this change?

06 / How it fails

Every failure here is a delivery that looks fine: the question is who you believed.

Each row except the last is a shared scenario the tests run.

Failure modes of receiving a payment provider’s webhooks
What happensTrusting the bodyRe-serialized signatureThe receiver
The same event arrives three timesCounted three times.Counted once.Recorded once, applied once.
A cancellation arrives before an older paymentThe subscription ends active.Canceled.Canceled.
A forged paymentApplied: revenue and an active subscription.401.401.
A genuine delivery replayed ten minutes laterCounted again.Acknowledged as a duplicate.401: the signature is too old.
The provider pretty-prints its JSONApplied.401 on every genuine delivery, retried for days.Applied.
The work is slowThe reply waits for the work and the provider retries a delivery already half done.Answered at once; the worker takes its time. Not modeled in the example.

Duplicates and retries have their own lessons: Idempotency and at-least-once and Validation at the edge.

07 / Is it worth it?

A receipt table and a worker, against a handler that trusts one request.

The shared four changes, against trusting the body
ChangeTrusting the bodyThe receiver
A second entry point: a nightly sync pulls events from the provider’s APIThe sync and the webhook both apply every event.The sync records events by id too; whichever arrives second changes nothing.
A new payment providerA new parser.A new signature scheme at the door; the receipt table and worker stay.
A new rule: refunds take revenue backA new branch.A new branch in the worker. No difference.
The finance team owns revenue reportingThey read what the handler wrote.They can replay the receipt table into their own report.

The costs are small: a table, a worker, and a status that can lag the provider by a moment. For an internal tool where you control both ends and nothing is public, a shared secret and a direct call may be enough. For money, it is not optional.

Measure before and after:

  • Reply time at the 99th percentile against the provider’s timeout.
  • Duplicate deliveries per day, which is how often the receipt table earns its keep, and signature failures per day, which should be near zero and spike when something changes the body.
  • Revenue in your report against the provider’s own report, reconciled daily.

This lesson did not measure a real platform, and gives no numbers.

08 / Ask for it

One brief, two prompts.

Two agents running Claude Sonnet each got the brief from section 01 in an empty folder, with the provider described the way its documentation would: the signature over the body as sent, retries, repeats, and no promise of order. One prompt added a Webhooks block: raw bytes before parsing, a five-minute window on the timestamp, a receipt by event id before the reply, the work after it, and ordering by created. A script played the provider and an attacker against both builds.

What the checker found, run 2026-09-23
QuestionPlain promptWebhooks prompt
The same event three times200, 200, 200; revenue 900 from 1 invoice200, 200, 200; revenue 900 from 1 invoice
A cancellation before an older paymentEnds canceledEnds canceled
A forged payment400; nothing changed400; nothing changed
A body changed after signing400; nothing changed400; nothing changed
A delivery signed ten minutes before it arrives200; applied, revenue 900400; nothing changed
A pretty-printed body200; applied, revenue 900200; applied, revenue 900
An event type nobody handles200200
Twenty events, five times each, all at once200 within 45 ms; 20 invoices counted200 within 25 ms; 20 invoices counted
Killed right after answering ten deliveries10 of 10 counted after the restart10 of 10 counted after the restart
Its own tests22 of 22 pass18 of 18 pass

The provider’s description did nearly all of the work. Both builds checked the signature over the raw bytes, so a pretty-printed body passed and a forged or tampered one did not; both counted a retried event once, kept a late payment from reviving a canceled subscription, and kept every answered delivery through a kill. The mistakes in section 03’s first two receivers were not made.

One question split them. A delivery whose signature is ten minutes old is exactly what someone who captured a real request would send. The plain build never looks at the timestamp it signs, so the old delivery was applied.

src/signature.ts · plain prompt
export function verifySignature(secret: string, header: string | undefined | null, rawBody: string): boolean {
  const parsed = parseSignatureHeader(header);
  if (!parsed) return false;

  const expected = createHmac("sha256", secret).update(`${parsed.timestamp}.${rawBody}`).digest();
server.ts · webhooks prompt
const CLOCK_TOLERANCE_SECONDS = 5 * 60;
…
  if (Math.abs(nowSeconds - timestamp) > CLOCK_TOLERANCE_SECONDS) {
    return { ok: false, reason: "timestamp outside tolerance" };
  }

So the line to carry is the one the provider’s description left out: refuse a delivery whose signed timestamp is more than five minutes from our clock. The webhooks build also did its work after the reply, as asked; with work this quick no question could tell, and with slow work it is what keeps the reply inside the provider’s ten seconds.

How the runs were made and checkedTwo builds, recorded as written
  • Both agents started in empty folders and were launched at the same time; neither was told about the other, the lesson, or the checker.
  • Both builds are kept byte for byte with checksums. For every question the checker restores a build into a fresh folder with its own database and secret, signs its own deliveries with node:crypto, and kills the server where a question needs it.
  • The checker ran twice; the first covered the plain build alone and gave the same answers.
  • Neither agent wrote outside its folder or stopped a process by name or pattern; the plain agent listed processes with ps and lsof before stopping its own by id.
  • One run of each prompt is a sample, not a measurement of the model.

09 / Hold it there

A webhook endpoint breaks when someone changes the framework. Three checks notice.

  1. The framework’s door: keep the body raw on this route

    Most frameworks parse JSON for you, and that is the change Stripe’s guide warns about: some “edit the request body by doing things like adding or removing whitespace, reordering the key-value pairs, converting the string to JSON, or changing the encoding. All of these cases lead to a failed signature verification.” For Express it adds: “make sure that app.use(express.json()) is placed after the webhook route.” (Stripe, webhook signature errors) In a fetch-style handler, read request.text(), never request.json(), on this route.

  2. Tests with pretty-printed, repeated, reordered, and old deliveries

    A test that sends one compact event proves nothing about the three failures above. The shared scenarios send each of them; a rule that the webhook route never calls a JSON parser before verification can be enforced the way Enforcement layer enforces import rules.

  3. Reconcile with the provider

    Once a day, list the provider’s events for the period through its API and compare them with the receipt table. A missing event means a delivery you refused or never got; a surplus means something you should not have accepted.

Build UIs?The redirect back from checkout is not the payment. The webhook is.

Where it already is in your components

Every hosted checkout sends the customer back to a return page. That page often says “Thank you, you’re subscribed” because the URL says so. The URL proves the customer came back, not that they paid; anyone can type it. The truth arrives by webhook, a moment later.

When you have to own it

When your return page and billing panel show the server’s status, they show the webhook’s result, which can lag. Say “Confirming your payment” until the server agrees, and ask again while the customer is looking, so a cancellation made in the provider’s portal shows up too.

A checkout return page that waits for the server to confirm the subscription instead of trusting the redirect.

ReactAlready in your code
CheckoutReturn.tsx
import { useEffect, useState } from 'react';

type Subscription = { status: 'active' | 'canceled'; paidThrough: number | null };

// The provider's checkout sends the customer back with ?session=… in the URL.
// That redirect proves the customer came back, not that they paid: anyone can
// type the URL. The page waits for the server, which waits for the webhook.
export function CheckoutReturn({ subscriptionId }: { subscriptionId: string }) {
	const [subscription, setSubscription] = useState<Subscription | null>(null);

	useEffect(() => {
		let stopped = false;
		async function poll() {
			const response = await fetch(`/api/subscriptions/${subscriptionId}`);
			const current = response.ok ? ((await response.json()) as Subscription) : null;
			if (current) setSubscription(current);
			if (!stopped && current?.status !== 'active') setTimeout(poll, 2000);
		}
		poll();
		return () => {
			stopped = true;
		};
	}, [subscriptionId]);

	if (subscription?.status === 'active') return <p>You are subscribed. Welcome aboard.</p>;
	return <p role="status">Confirming your payment with the provider…</p>;
}

10 / Make the call

Believe the bytes, not the body. Record before you answer.

Any endpoint that another company calls about money, access, or data gets the full sequence: raw bytes, signature and age, receipt by id, quick answer, ordered work. Skip the worker only when the work is fast and cannot fail; skip nothing else. Reopen the design if the provider offers a way to fetch events by id: then the webhook can be just a nudge, and the worker reads the event from the source.

Take it with you

Explain it without saying “webhook”: “The bank calls us to say a payment went through. We check the call really came from the bank and is recent, write it down under the bank’s reference number, say thanks, and then update the account, ignoring any call about a reference we already have.” Then find an endpoint in your own code that another system calls, and ask what happens if that call arrives twice.

Paste into your next prompt, and fill in the blanks

[The payment provider] calls [POST /webhooks/payments] with events signed as [t=<timestamp>,v1=<HMAC-SHA256 of timestamp.body>].
A delivery is untrusted input. Verify the signature over the raw bytes of the body before parsing it, with a timing-safe compare, and refuse a delivery whose timestamp is more than [five minutes] from our clock.
Record every accepted delivery by its event id before replying 2xx; a delivery whose id is already recorded is acknowledged and changes nothing.
Reply within [one second], and apply recorded events in a worker after the reply; a restart resumes where it stopped.
Events arrive late and out of order: apply them per [subscription] by their created time, so an older event never overwrites a newer one's effect, and count each [invoice] once.
Connections to follow nextRelated lessons

Take the receiver into your editor. Add a second signing secret that is accepted for a week, so the secret can be rotated without refusing a single genuine delivery.

Back to architecture →