tfpdev
03 / 06Delivery

Webhooks

The card company tells your shop 'payment received' by sending a message. That message can arrive twice, late, in the wrong order, or from an impostor. Here you play the card company.

01Step 1

Try it

A webhook is the card company's messenger. It knocks twice when it is unsure, it arrives late, and sometimes someone knocks pretending to be it. Below is your own mailbox. Send it letters, or press Chaos and send everything at once.

Letters, as they arrive
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
The order—
Packed and sent—A trusting mailbox—
Be the messenger120 of 120 sends left this hour
two letters, in order
the same letter, five times
the older letter comes last
a fake 'money taken'
10 letters at once: copies, late, fakes

Your mailbox is empty. Send letters and watch what it does with each one. Chaos sends ten at once: copies, late ones and fakes.

What the database wrote down, row by row
wh.deliveries — every HTTP hit on your inbox0 rows
AtViaEventTypeResultms
Nothing yet. Press a button or run the curl below.
wh.orders — the projection0 rows
OrderStateLast event
No orders in your inbox yet.

Engineers can send letters to this mailbox from their own terminal. Its address, its password and the command are one click away.

Show the command
Your inbox
https://tfpdev.com/api/lab/money/webhooks/in/<inbox>
secret whsec_moneylab_demo_2026
Signed with HMAC-SHA256 over the raw body. The secret is public because the inbox is yours alone and the limit is 120 sends an hour.
From your terminal
BODY='{"id":"evt_curl_1","type":"payment.captured","order_id":"ord_curl","created":'"$(date +%s)"'}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "whsec_moneylab_demo_2026" | sed 's/^.* //')
curl -s -X POST "https://tfpdev.com/api/lab/money/webhooks/in/<inbox>" \
  -H "Content-Type: application/json" -H "X-Signature: sha256=$SIG" -d "$BODY"
# run it twice: the second answer is {"result":"duplicate"}

Limit: 120 sends per hour per visitor, buttons and curl combined. Deliveries, events and orders older than 24 hours are deleted.

02Step 2

What the mailbox does

Check the signature first

Every real message carries a signature only the card company can produce. The mailbox checks it before reading a word. Without that, anyone could tell your shop an order is paid.

Every message has a number

The card company numbers its messages. The mailbox keeps every number it has seen. A repeat is thanked and ignored, even when five copies arrive in the same instant.

Newer wins, old news is ignored

Messages can arrive in the wrong order. Each one says when it was written. An older message never overwrites a newer state of the order.

03Step 3

The code

What the mailbox does, in one sentence. The code itself is one click away.

Check the signature. Write the message number down once; if it is already there, say 'got it' and stop. Then move the order forward only if this message is newer than the last one.

Show the code anyway
BreaksBreaks
The version that loses money. Read the grey notes under it, not the code.
handler-naive.tsts
export async function POST(req) {  const event = await req.json();  if (event.type === "payment.captured") {    await db.query(      "UPDATE orders SET state = 'captured' WHERE id = $1",      [event.order_id]    );    await fulfil(event.order_id);   // runs once per delivery, not once per event  }  return new Response("ok");}
Trusts the sender, trusts the order of arrival, and processes a redelivery as a new event. Providers redeliver on every timeout. They say so in the docs.
HoldsHolds
The version running behind the buttons above.
handler.tsts
const raw = await req.text();if (!verify(raw, req.headers.get("x-signature"))) return new Response(null, { status: 401 });const ev = parse(raw);await db.query("BEGIN");const ins = await db.query(  "INSERT INTO webhook_events (id, type, order_id, created_at) VALUES ($1,$2,$3,to_timestamp($4)) ON CONFLICT DO NOTHING RETURNING id",  [ev.id, ev.type, ev.order_id, ev.created]);if (ins.rowCount === 0) { await db.query("ROLLBACK"); return json({ result: "duplicate" }); }const upd = await db.query(  `INSERT INTO orders (id, state, last_event_at) VALUES ($1, $2, to_timestamp($3))   ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, last_event_at = EXCLUDED.last_event_at   WHERE orders.last_event_at < EXCLUDED.last_event_at   RETURNING state`,  [ev.order_id, STATE[ev.type], ev.created]);await db.query("COMMIT");return json({ result: upd.rowCount ? "processed" : "stale" });
Verify, claim the event id, move the order forward only if this event is newer. All three in the order shown. Dedupe and effect commit together.
04Step 4

Still curious?

A few more questions people ask. Open what you need.

Why say 'got it' to a repeat instead of 'error'?

If the mailbox says 'error', the messenger comes back again and again. Saying 'got it' makes it stop.

What if the messages do not say when they were written?

Then the message is only a nudge. The shop calls the card company and asks what the real state is right now.

Where is the secret kept?

In a safe place on the server, never inside the code. The secret shown on this page is public on purpose; your mailbox here is a toy.

Why write 'seen it' and do the work in one go?

If the shop writes 'seen it' and then crashes before doing the work, that message is lost for good; the messenger will not come back. Write both down together, or neither.

Why this exists

Ticketing: exactly-once webhook processing

This article is the toy version of a problem from a real project. The case page tells what happened and what it cost.

Read the case