ConceptsIdempotency

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:

LegDerived key
Commerce ordercheckout:K
Payment intentcheckout:K:intent
Capture / chargecheckout:K:capture
Store-credit tendercheckout:K:storecredit
Gift-card tendercheckout: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 201 with outcome: "placed".
  • A replay (same key, previously placed) returns 200 with replayed: 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 receive route (.../returns/{id}/receive) receives, restocks, and refunds in one idempotent step; it also requires the header, and requires a charge_id in 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:

  1. 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.
  2. Reuse the identical key on every retry of the same business action. A new key is a new action.
  3. Retry on 5xx and on network timeouts — those are exactly the “did it happen?” cases the key exists to make safe. The replay is free of double-effects.
  4. Do not retry 4xx. A 400 missing_idempotency_key means you omitted the header; a 409 scope_escalation, slug_exists, sku_exists, or no_card_charge is a deterministic verdict that a retry will only repeat.
  5. Read replayed / outcome (and the 201 versus 200 split) to tell a fresh effect from a replayed one when your own bookkeeping needs it.
  • 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.