API ReferenceIdempotency

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 hard 400 with IDEMPOTENCY_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 stateResponse
First time it sees this keyProcess normally, store the response with TTL 24h, return as usual
Replay within 24h, same request bodyReturn the stored response, plus X-Idempotent-Replayed: true
Replay within 24h, different request body409 Conflict with code IDEMPOTENCY_KEY_REUSED
A concurrent request holds the same key409 Conflict with code IDEMPOTENCY_KEY_REUSED — retry after it completes
Replay after 24hTreated as a new request (no dedup)
Lock store unreachable, key required503 with code IDEMPOTENCY_LOCK_UNAVAILABLE — nothing processed; retry the same key
Lock store unreachable, key optionalRequest 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:

RouteWhat a duplicate costs you
POST /v1/orders/{id}/shipments/app-labelBuys 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/mediaA duplicate asset in the media library, and a second upload charge
PATCH /v1/products/{id}/convert-to-variableRepeats a structural product change
PATCH /v1/products/{id}/convert-to-simpleRepeats 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-defaultIdempotent in effect, but not deduped
POST /v1/tax-classes/{id}/assignRe-runs the assignment
POST /v1/tax-classes/{id}/merge-into/{target}Destructive — a second merge cannot be undone
PATCH /v1/regions/{regionId}/set-defaultIdempotent in effect, but not deduped
PUT /v1/regions/{regionId}/payment-providersRe-writes the provider list
POST /v1/regions/{regionId}/countriesRe-adds the country
DELETE /v1/regions/{regionId}/countries/{code}Second delete 404s
POST /v1/storefront-bot/conversations/{id}/summarizeSpends 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

EndpointWhy it matters
POST /api/v1/checkout/:checkoutId/completeRequired. This charges the card.
POST /api/v1/ordersA retried timeout would otherwise create a second order.
POST /api/v1/productsProduct creation.
POST /api/v1/products/bulkCatalog import — see the note below; this one has a second, durable layer.
POST /api/v1/customersCustomer creation.
POST /api/v1/couponsCoupon creation.
POST /api/v1/checkout/:checkoutId/gift-cardA 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:

  1. Idempotency-Key header, or the idempotencyKey field in the body. Either one returns the ORIGINAL jobId on 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 the bulk_create_products MCP tool has no HTTP headers to carry one.
  2. Row-level dedup. Independently of any key, a row whose sku or externalId already 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-Key on a read is a 400, not a no-op.
  • Concurrent duplicate clicks on unsupported routes. On supported routes a short Redis lock makes the second concurrent request 409 rather than double-executing; elsewhere, the checkout's own paymentIntentId lock 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.