Idempotency
A retry must never double-charge, double-refund, or double-post to the ledger. Flightdeck guarantees this with deterministic idempotency keys derived from the business event, backed by unique constraints at the database layer. This page is the exact contract per route class, and how to build retries that lean on it.
Why deterministic, not random
An idempotency key must be derived from the business event — never
randomUUID() (ADR-0001, FD-11). The reason is the failure
mode you actually hit: your request times out, you never saw the response, and
you do not know whether the write happened. If your key was random, your retry
carries a new key and posts a second charge. If your key is deterministic
— the same for “attempt 1” and “attempt 2” of the same cart submission — the
retry replays the original outcome instead.
Deterministic means reproducible from data you hold before the call: a cart
id, an order id, a billing period. A good key looks like cart-8c1f2a:attempt-1
where the attempt-1 suffix is stable for that cart, not incremented per
network try.
Header versus body
The key travels in the Idempotency-Key HTTP header on the money-path
routes that accept one — not in the request body.
curl -X POST "https://api.ruo.pro/v1/tenants/TENANT/checkout" \
-H "x-storefront-token: $STOREFRONT_TOKEN" \
-H "Idempotency-Key: cart-8c1f2a:attempt-1" \
-H "content-type: application/json" \
-d '{ "cart": { /* ... */ } }'The header spelling is exactly Idempotency-Key (the SDK sends this casing;
HTTP header matching is case-insensitive, so a lowercase idempotency-key
also works). It is required on the routes that take it — a missing or
empty value is a 400 missing_idempotency_key (“Idempotency-Key header
required”), not a silently un-deduped write.
How a single key fans out
Checkout does not cache your HTTP response under your key. It threads your key
into a family of derived, namespaced keys, one per money-moving leg, so
each downstream write is independently idempotent under the same intent. A
single checkout submit with key K produces, among others:
| Leg | Derived key |
|---|---|
| Commerce order | checkout:K |
| Payment intent | checkout:K:intent |
| Capture / charge | checkout:K:capture |
| Store-credit tender | checkout:K:storecredit |
| Gift-card tender | checkout:K:giftcard |
| Refund (from the storefront route) | checkout:K:refund |
Each derived key lands in a money-path table that carries an
idempotency_key column with a UNIQUE (tenant_ref, idempotency_key) index.
The race is closed by that unique index — an ON CONFLICT DO NOTHING insert
(or a pre-check inside the same transaction), then a re-select of the existing
row — not by a bare check-then-insert. This is the mechanism behind
the ledger’s deterministic idempotency: accounting,
payments, commerce orders, cash, giftcards, and inventory
reservations all use the same (tenant_ref, idempotency_key) pattern.
Replay semantics per route class
The replay contract differs by route class. Know which one you are calling.
Checkout and returns-receive — you supply the key
POST /v1/tenants/{t}/checkout, the storefront refund/cancel/edit routes, and
the returns receive routes all require the Idempotency-Key header.
- A fresh placement returns
201withoutcome: "placed". - A replay (same key, previously placed) returns
200withreplayed: true,outcome: "already_placed", and the original order — no re-charge and no re-publish of the order event. Gift-card issuance and subscription-ensure are re-run on replay (each is itself idempotent) to self-heal a first attempt that placed the order but crashed mid-way. - The returns
receiveroute (.../returns/{id}/receive) receives, restocks, and refunds in one idempotent step; it also requires the header, and requires acharge_idin the body (the original charge to refund against).
Note the split status codes: 201 means your request did the work, 200
means it replayed an earlier outcome. Both are success. Treat either as “the
order exists,” and read replayed / outcome if you need to distinguish.
Refund and cancel from the merchant portal — auto-idempotent per order
The merchant portal’s order actions (POST /v1/merchant/orders/{id}/refund
and .../cancel, authenticated with an fdk_ key) take no
Idempotency-Key header. The key is derived deterministically from the order
id — merchant-refund:{orderId} and merchant-cancel:{orderId}:refund — so
the operation is auto-idempotent per order. Clicking “refund” twice refunds
once. The charge to refund against is reconstructed from the order’s stored
key ({orderKey}:capture), not stored separately. A merchant refund on an
order with no card charge is a 409 no_card_charge (“cancel it instead”).
Catalog writes — no key, converge on 409
Catalog mutations (create product, variant, collection) take no
idempotency key at all. They rely on the natural uniqueness of slug and
sku. A retry that re-sends the same create converges on a domain
conflict rather than a duplicate row:
- duplicate product/collection slug ->
409 slug_exists - duplicate variant sku ->
409 sku_exists
Treat these 409s as “already exists, you are done” for a create you intended
to make once. They are the idempotency contract for catalog writes — there is
no separate key to manage.
A return request (createReturn) is deliberately not idempotency-keyed
— it is an explicit shopper/operator action, and two requests mean two
returns. It is the return’s receive/settle step (above) that is
idempotent. Do not retry a return-request blindly.
What a replayed response looks like
Because replay returns the original outcome, a replayed checkout carries
replayed: true and the original order body. The distinction between “your
call did it” and “your call replayed it” is always visible in the body
(replayed, outcome) and, for checkout, in the status code (201 versus
200). You never have to guess.
Partial failure is never a false 2xx
The dangerous case is a captured charge followed by a failed order transition.
Checkout never answers 2xx in that state. It writes a payments.audit_log
entry and returns a 5xx naming the charge_id, intent_id, and order_id.
A same-key retry then replays the captured charge (the payments repo
reports replayed: true) and proceeds from there — which is the entire reason
the key is deterministic. The same principle holds for in-process event
consumers: a consumer failure surfaces as a retryable 5xx, and the retry
replays rather than double-posts (see Architecture).
How to build safe retries
Build every money-path retry on these rules:
- Compute the key before the first call, from data you already hold (cart id, order id, period). Persist it if the caller is stateless across retries. Never generate it per network attempt.
- Reuse the identical key on every retry of the same business action. A new key is a new action.
- Retry on
5xxand on network timeouts — those are exactly the “did it happen?” cases the key exists to make safe. The replay is free of double-effects. - Do not retry
4xx. A400 missing_idempotency_keymeans you omitted the header; a409 scope_escalation,slug_exists,sku_exists, orno_card_chargeis a deterministic verdict that a retry will only repeat. - Read
replayed/outcome(and the201versus200split) to tell a fresh effect from a replayed one when your own bookkeeping needs it.
Related
- Money invariants — integer cents, double-entry ledger, and where deterministic keys sit in the money rules.
- Architecture — how a consumer failure becomes a
retryable
5xx. - Errors — the nested error body and status codes these routes return.