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
- A row per key per object. A placement writes three: the Sandbox’s mapping, and a
commands_seenrow in the Schedule and in the Order. Nothing prunes them: they live until the sandbox is wiped at 10:00 UTC, or until an object’s 36-hour backstop deletes it (docs/domain.md, “Idempotency”). The draft asks each server to publish its expiry, and this is the bakery’s: under a day and a half. - A 422 a client can earn. A client that reuses a key for a changed cart gets 422, not an order. The storefront avoids it by minting a key for every changed attempt.
- A body hash on every command, and the answer stored beside it.
- One key for every distinct call to Stripe, which is why the keys carry suffixes and an attempt number: Stripe refuses one key on two different requests.
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
- The Idempotency-Key HTTP Header Field, an Internet-Draft of the IETF’s HTTPAPI working group, version 08 of 2026-10-22, read on 2026-11-06: 400 for a missing key, 422 for a key reused with another payload, 409 for a repeat while the first is still running, and an expiry policy each server publishes.
- Idempotent requests, Stripe’s API reference, read on 2026-11-06: the status and body of the first request saved and returned to every retry, 500s included; keys that may be pruned once 24 hours old; parameters compared and a mismatch refused.
- Receive Stripe events in your webhook endpoint, Stripe’s documentation, read on 2026-11-06: the same event delivered more than once, guarded by logging event ids; no promise of order; sandbox retries three times over a few hours, live mode for up to three days; a five-minute tolerance on the signature’s timestamp.
- SQLite-backed Durable Object Storage, Cloudflare’s Durable Objects documentation, read on 2026-11-06: transactionSync() rolls back when its callback throws, and takes only synchronous storage operations.
- Idempotent Receiver, Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns (2003), read on 2026-11-06: a receiver that can safely receive the same message several times.
- The lab, ihaveasegway.com, read on 2026-11-06: five lab pages, none of them on idempotency keys; the Queue Lab’s workers check a message’s id before printing.
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.