How it’s built

Why pressing the button twice never orders twice

A request that may have arrived is sent again, and the bakery has to tell a retry from a new order. This is how a key does that, why it only works when it is written with the order, and how the same idea meets Stripe, which keeps its own clock.

Place order, pressed twice

A visitor on a train orders three morning buns for a 09:15 pickup and presses Place order. A tunnel takes the signal, and the page says the API is unreachable. The order may never have arrived, or it may be placed and holding three buns while the answer was lost on the way back. The visitor presses again.

A server that runs every request it receives places a second order, and the day’s count of morning buns drops by six. Only the server can tell the two apart, and only if they carry something that says they are one intent.

What a simpler design does, and where it breaks

The usual first answer is a key the client makes and a table of keys the server has seen, checked before the work and written after it. That works until the process stops between the two. If the order commits and the key is lost, the retry finds no key and places the order again. If the key is written first and the order then fails, the retry finds the key and answers for an order that does not exist. A key kept in another store, a cache or a second database, has the same gap.

Replying “already done” to a seen key breaks the client too: the retry needs what the first attempt returned, the order’s id, its total and its hold.

No lab page shows this one yet. The Queue Lab shows the receiving half, workers that check a message’s id before printing, and the lab is where the family of techniques is explained.

The key is written with the order

Every POST to the API needs an Idempotency-Key header holding a ULID. A missing or malformed one is refused with 400 idempotency-key before anything else runs (workers/api/src/http/mod.rs), the status the IETF’s draft gives for a missing key. The storefront makes the key once per attempt (apps/web/src/lib/attempt.ts): the same key while the cart and the form are unchanged and the last failure was the network or a 5xx, which may have been applied, and a new one after a 4xx, which applied nothing, or after any change.

Placing an order passes the key through three objects, each of which remembers it. The Sandbox maps it to one order id in its order_keys table, so a retry addresses the same Order object. The day’s Schedule holds the places under hold: and the key. The Order records it last. The Schedule and the Order each keep acommands_seen table of the key, a SHA-256 hash of the request body, the answer they gave and the time (workers/api/src/objects/stream.rs), as every aggregate class does.

The nuance is where that row is written. The Order appends its events, copies them to its outbox, sets its expiry timer and inserts the commands_seen row inside onetransactionSync, which Cloudflare’s documentation says is rolled back if its callback throws. There is no moment when the order exists and its key does not, or the other way round. A key is only as good as the transaction it is stored in.

A replay is the first answer

When a command arrives, the object reads its row for the key before it decides anything (check_command in crates/domain/src/outbox.rs). No row: the command is decided and committed. A row with the same body hash: the stored answer goes back as it was, with Idempotent-Replayed: true, and 200 where the first answer was 201. Nothing is decided again and no event is appended, so the buns are counted once.

A row with a different hash means the client sent one key with two bodies, which is the client’s bug, not a retry. It is refused with 422 idempotency-key, reason key-reused, the status the IETF draft gives for that case. Guessing which body was meant would place an order nobody asked for.

A replay can be out of date. A hold released by its timer, or given back after the Order refused, would replay a stale “held”. Since the hardening of 2026-11-06 the day decides such a key again, as Retake: the same place is held again with the retry’s own 15 minutes when there is room, or the retry is refused with 409 hold-lost, naming the next free slot.

The second edge: Stripe’s keys and Stripe’s clock

Stripe is a second system with its own state, so the same problem returns at the edge. The Billing object commits its decision first, then calls Stripe with a key the committed state names: <sandbox>:<ref>:<purpose>:<attempt> to create an intent, the purpose suffixed with the call for the others, such as -refund (crates/app/src/stripe/request.rs). The order’s id is the reference. The sandbox leads because two sandboxes born from one seed share their seeded ids, and Stripe’s reference, read on 2026-11-06, says it saves the first status and body for a key, 500s included, keeps the key for at least 24 hours, and refuses a retry whose parameters differ. Without the sandbox, a second visitor’s payment would get the first one’s answer.

The order is paid only when Stripe’s signed webhook says so. Stripe’s documentation says an event can arrive more than once and in no promised order, and tells you to log the event ids you have processed. Billing records each id inside the event it produced, and its state keeps them as webhooks_seen, so a redelivery is answered 200 and changes nothing (crates/domain/src/billing.rs). Order is handled by refusing what cannot apply yet: a refund that arrives before its payment is answered 409, and Stripe delivers it again later. A payment that succeeds after its hold expired is recorded anyway, because the money moved, and the Order refunds it as late. It is the header’s idea with someone else’s clock: a webhook that comes twice or late changes nothing it should not.

What it costs

Try it

Sign in, issue a token in the console, and start the tour on the checkout. The step for placing an order shows its request as a curl with an Idempotency-Key. Run it once and the answer is 201 with a new order; run it again unchanged and the answer is 200, the same order, withIdempotent-Replayed: true. Change a quantity and keep the key, and the answer is 422. Then pay for an order with the test card 4242 4242 4242 4242, and find the payment once in the console’s ledger.

Sources

Back to idempotency keys and idempotency keys on payments and webhooks on How it’s built, to the decisions that chose them, ADR 0004 and ADR 0007, or to why a single writer needs no consensus.