Idempotency
How to safely retry POST/PATCH/PUT/DELETE requests without creating duplicates — and which routes do not support it.
Why it matters
Networks fail. Your client may not learn whether a POST /v1/orders succeeded or timed out before the response came back. Retrying without an idempotency key means you create two orders — and double-charge the customer.
The idempotency key lets the server dedupe retries: if it has already seen the same key in the last 24 hours, it returns the stored response instead of doing the work again.
Support is per route, not blanket
Idempotency is opt-in per endpoint. A route either declares support or it does not, and there is no way to tell from the response which you got:
- On a supported route, your key is honoured — first call runs, replays return the cached response with
X-Idempotent-Replayed: true. - On an unsupported route, the header is read by nothing. The request executes normally, every time. You get no warning, no header, and no error — a retry does the work twice.
- On a
GET, sending the header is a hard400withIDEMPOTENCY_KEY_NOT_SUPPORTED.
Most mutating /v1/* routes support it. The exceptions are listed below; check that list before you build a retry loop.
How to use it
Send Idempotency-Key: <unique-key> on any mutating request you might retry:
POST /api/v1/orders
Authorization: Bearer brainerce_xxx
Idempotency-Key: 89c2e1c8-2adf-4f55-b6ea-13e8b1a6c8e2
Content-Type: application/json
{ "customerId": "cust_xxx", "items": [...] }The key must be:
- Unique per logical operation. A UUIDv4 is ideal. Don't reuse the same key for a different order.
- Generated by the client. The server treats whatever you send as the cache key — it does not invent one for you.
- At most 255 characters. Longer keys are rejected with
400/VALIDATION_FAILED.
What the server does
| Server state | Response |
|---|---|
| First time it sees this key | Process normally, store the response with TTL 24h, return as usual |
| Replay within 24h, same request body | Return the stored response, plus X-Idempotent-Replayed: true |
| Replay within 24h, different request body | 409 Conflict with code IDEMPOTENCY_KEY_REUSED |
| A concurrent request holds the same key | 409 Conflict with code IDEMPOTENCY_KEY_REUSED — retry after it completes |
| Replay after 24h | Treated as a new request (no dedup) |
| Lock store unreachable, key required | 503 with code IDEMPOTENCY_LOCK_UNAVAILABLE — nothing processed; retry the same key |
| Lock store unreachable, key optional | Request runs normally, without dedup |
The "same body" check is a fingerprint of method + path + canonical JSON body. Query strings are excluded — a retry that changes only a query param is treated as the same request, not a conflict.
Keys are scoped to the credential that sent them (apikey:<id>, or the store/user for dashboard sessions). The same key value from two different API keys is two different requests.
Checkout completion requires a key
POST /api/v1/checkout/:checkoutId/complete is the one route in the API where the header is mandatory, not optional. Omit it and the request is rejected before the handler runs:
{
"statusCode": 400,
"code": "MISSING_REQUIRED_FIELDS",
"message": "Idempotency-Key header is required for this endpoint"
}This is deliberate: that call captures the card. If your checkout integration is failing with a 400 and nothing about the body looks wrong, this is why.
It also fails closed — 503 IDEMPOTENCY_LOCK_UNAVAILABLE
Because the key is mandatory here, the once-only lock is the only thing standing between a double-submit and a second capture. If that lock cannot be taken — the lock store is briefly unreachable — this route answers 503 rather than running unguarded:
{
"statusCode": 503,
"code": "IDEMPOTENCY_LOCK_UNAVAILABLE",
"message": "Could not guarantee this request runs only once. Nothing was processed. Retry shortly."
}Nothing was processed. No order was created and no card was charged. Retry the identical request with the same Idempotency-Key after a short backoff.
⛔ Do not generate a new key for this. IDEMPOTENCY_LOCK_UNAVAILABLE is a 503, not the 409 IDEMPOTENCY_KEY_REUSED above; only that 409 means "your key is spent, mint a new one". Rotating the key here throws away the protection you asked for and sends an unguarded second attempt at the same payment. No other route can return this code, because complete is the only one that requires a key.
Routes that do NOT support it
These /v1/* routes carry no idempotency support today. A key sent to a mutating one is silently ignored — the operation runs again on every retry:
| Route | What a duplicate costs you |
|---|---|
POST /v1/orders/{id}/shipments/app-label | Buys a second shipping label from the carrier — real money, billed to your carrier account. A network timeout on this call is genuinely expensive; check GET /v1/orders/{id}/shipments before re-sending rather than blind-retrying. |
POST /v1/media | A duplicate asset in the media library, and a second upload charge |
PATCH /v1/products/{id}/convert-to-variable | Repeats a structural product change |
PATCH /v1/products/{id}/convert-to-simple | Repeats a structural product change |
POST /v1/sync, and GET /v1/sync/{jobId} | Returns 501 NOT_IMPLEMENTED regardless — see Errors |
PATCH /v1/tax-classes/{id}/set-default | Idempotent in effect, but not deduped |
POST /v1/tax-classes/{id}/assign | Re-runs the assignment |
POST /v1/tax-classes/{id}/merge-into/{target} | Destructive — a second merge cannot be undone |
PATCH /v1/regions/{regionId}/set-default | Idempotent in effect, but not deduped |
PUT /v1/regions/{regionId}/payment-providers | Re-writes the provider list |
POST /v1/regions/{regionId}/countries | Re-adds the country |
DELETE /v1/regions/{regionId}/countries/{code} | Second delete 404s |
POST /v1/storefront-bot/conversations/{id}/summarize | Spends AI credits again |
The marketplace-app routes under /v1/installations/{installationId}/* — mappings and secrets — also have no idempotency support. Use PUT /v1/installations/{installationId}/mappings/upsert, which is idempotent by construction (it upserts on (installationId, resourceType, internalId, platformCode)), rather than retrying POST /mappings.
Where it matters most
| Endpoint | Why it matters |
|---|---|
POST /api/v1/checkout/:checkoutId/complete | Required. This charges the card. |
POST /api/v1/orders | A retried timeout would otherwise create a second order. |
POST /api/v1/products | Product creation. |
POST /api/v1/products/bulk | Catalog import — see the note below; this one has a second, durable layer. |
POST /api/v1/customers | Customer creation. |
POST /api/v1/coupons | Coupon creation. |
POST /api/v1/checkout/:checkoutId/gift-card | A retried timeout would otherwise place a second hold on the same card. |
Bulk imports have a second, durable layer
POST /api/v1/products/bulk is the one endpoint where the header alone is not
the whole story, because a catalog import is exactly the operation people retry
days later.
Two mechanisms stack:
Idempotency-Keyheader, or theidempotencyKeyfield in the body. Either one returns the ORIGINALjobIdon a re-send instead of starting a second import. The body field exists because it is stored in the database rather than in Redis, so it outlives the header's 24-hour window — and because thebulk_create_productsMCP tool has no HTTP headers to carry one.- Row-level dedup. Independently of any key, a row whose
skuorexternalIdalready exists in the store is skipped rather than created again. This is a database check, so it holds no matter how much time has passed, how the batch was chunked, or whether a key was sent at all.
The gap to know about: a row carrying neither a sku nor an externalId
has nothing to match on, and will be created again by a re-send that falls
outside the key window. Set externalId on rows without SKUs.
The 24-hour window is a real edge
A cached 4xx is replayed for the full 24 hours, the same as a success. So if a batch is rejected because it would exceed your plan's product limit, upgrading the plan and retrying with the same key replays the cached rejection rather than importing. Generate a new key after fixing whatever caused a 4xx.
5xx responses are never cached — a 500 or a timeout stays retryable, which is
the entire point of an idempotency key.
What this does NOT cover
- GETs are already idempotent by definition. Sending
Idempotency-Keyon a read is a400, not a no-op. - Concurrent duplicate clicks on unsupported routes. On supported routes a short Redis lock makes the second concurrent request
409rather than double-executing; elsewhere, the checkout's ownpaymentIntentIdlock is what protects you. - Cross-store dedup. Keys are scoped per credential. The same key sent from two different API keys is two different requests.