Idempotency
A retry must never double-charge, double-refund, or double-post. RUO Pro guarantees this with deterministic idempotency keys derived from the business event. 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 a random value. 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 makes its downstream write independently idempotent, so a retry under the same intent replays each leg instead of repeating it. This is the mechanism behind deterministic idempotency across the money path: orders, payments, store-credit and gift-card tenders, and inventory reservations all replay the same way.
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 returns a 5xx naming the
charge_id, intent_id, and order_id. A same-key retry then replays the
captured charge (the response reports replayed: true) and proceeds from
there — which is the entire reason the key is deterministic. The same principle
holds for downstream processing: a failure surfaces as a retryable 5xx, and
the retry replays rather than double-posts.
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, fail-closed money rules, and where deterministic keys sit.
- Errors — the nested error body and status codes these routes return.