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:
| Part | Owns | Promises |
|---|---|---|
| The provider | What happened, the event id, and when | A signature over its bytes; retries until a 2xx; no order |
| The door | Whether to believe a delivery | A 401 for a bad or old signature; a 2xx only after recording |
| The receipt table | Which events arrived, once each | A second copy changes nothing |
| The worker | Subscriptions and revenue | Applies 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.
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
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.
/**
* 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;
} // 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
/** 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.
/** 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.
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.
| What happens | Trusting the body | Re-serialized signature | The receiver |
|---|---|---|---|
| The same event arrives three times | Counted three times. | Counted once. | Recorded once, applied once. |
| A cancellation arrives before an older payment | The subscription ends active. | Canceled. | Canceled. |
| A forged payment | Applied: revenue and an active subscription. | 401. | 401. |
| A genuine delivery replayed ten minutes later | Counted again. | Acknowledged as a duplicate. | 401: the signature is too old. |
| The provider pretty-prints its JSON | Applied. | 401 on every genuine delivery, retried for days. | Applied. |
| The work is slow | The 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.
| Change | Trusting the body | The receiver |
|---|---|---|
| A second entry point: a nightly sync pulls events from the provider’s API | The sync and the webhook both apply every event. | The sync records events by id too; whichever arrives second changes nothing. |
| A new payment provider | A new parser. | A new signature scheme at the door; the receipt table and worker stay. |
| A new rule: refunds take revenue back | A new branch. | A new branch in the worker. No difference. |
| The finance team owns revenue reporting | They 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.
| Question | Plain prompt | Webhooks prompt |
|---|---|---|
| The same event three times | 200, 200, 200; revenue 900 from 1 invoice | 200, 200, 200; revenue 900 from 1 invoice |
| A cancellation before an older payment | Ends canceled | Ends canceled |
| A forged payment | 400; nothing changed | 400; nothing changed |
| A body changed after signing | 400; nothing changed | 400; nothing changed |
| A delivery signed ten minutes before it arrives | 200; applied, revenue 900 | 400; nothing changed |
| A pretty-printed body | 200; applied, revenue 900 | 200; applied, revenue 900 |
| An event type nobody handles | 200 | 200 |
| Twenty events, five times each, all at once | 200 within 45 ms; 20 invoices counted | 200 within 25 ms; 20 invoices counted |
| Killed right after answering ten deliveries | 10 of 10 counted after the restart | 10 of 10 counted after the restart |
| Its own tests | 22 of 22 pass | 18 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.
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(); 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
psandlsofbefore 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.
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, readrequest.text(), neverrequest.json(), on this route.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.
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.
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
- Work queues and background jobs runs the work after the reply.
- Event-driven architecture is the same idea inside your own system: facts recorded, readers following.
- Transactional outbox is the sending side: how to emit an event you will not lose.
- Idempotency and at-least-once explains why a delivery must be safe to receive twice.
- Validation at the edge is the general rule for untrusted input.