Idempotency keys
Retry requests that change things without doing them twice, which routes accept an Idempotency-Key, and how to generate keys and retry safely.
Networks fail. A request can reach the API and do its work, and the response can still be lost on the way back. An Idempotency-Key header lets you send the same request again and be sure it only happens once: the same order, the same basket line, the same form submission.
How it works
Generate a unique key for each action the shopper or your system takes, and send it as the Idempotency-Key header. Reuse that key only to retry that same action.
curl -X POST https://api.elaraone.app/api/v1/checkout/complete \
-H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
-H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
-H "X-Cart-Token: $CART_TOKEN" \
-H "Idempotency-Key: 3f0c9a52-8a8e-4c6f-9d1b-5e2a7b4c9d10" \
-H "Content-Type: application/json" \
-d '{ "payment": { "gateway": "stripe", "payment_intent": "pi_3QxYz…" } }'What happens to a request with a key:
| Situation | Answer |
|---|---|
| First time the key is seen | The request runs normally. |
| The first request succeeded (any status below 400) | The stored response is replayed exactly, with the header Idempotent-Replayed: true. Nothing runs again. |
| The first request is still running | 409 IDEMPOTENCY_IN_PROGRESS. Wait a moment and retry with the same key. |
| The first request failed (any status of 400 or above) | Nothing was stored, so the request runs again. |
| The key already holds a success, and this request is different | 422 IDEMPOTENCY_KEY_REUSED. Nothing runs. |
Only successes are stored
A success is kept for 24 hours and replayed to any retry in that time. Failures are not kept, on purpose: a 402 PAYMENT_FAILED can succeed once the shopper fixes their card, and a 422 for stock can succeed once stock returns. Every refusal on these routes happens before anything is written, or is rolled back, so running the request again can never do it twice.
What counts as the same request
A key is tied to the request it was first used with: the body, the path (including ids such as a cart line's), and the caller's credentials (Authorization, X-Cart-Token, X-Chat-Token). Reusing a key on another cart, another line or another customer's token returns 422 IDEMPOTENCY_KEY_REUSED instead of someone else's response.
Keys are scoped to the store and the route. Two stores, or two different routes, can never collide, even if they use the same key.
Routes that accept a key
| Route | Why |
|---|---|
POST /v1/cart/lines, PATCH /v1/cart/lines/{id}, DELETE /v1/cart/lines/{id} | A retry never adds or removes a line twice |
POST /v1/checkout/complete | Exactly one order per checkout |
POST /v1/checkout/paypal/capture | Exactly one capture and one order |
POST /v1/forms/{handle} | One submission, one lead |
PUT /v1/consent | One consent change |
POST /v1/marketing/confirm | One confirmation |
POST /v1/chat/messages | One message |
POST /v1/merchant/crm/contacts, /companies, /tasks and /{type}/{id}/notes | No duplicate CRM records |
POST /v1/merchant/orders/{reference}/dispatch, /offline-refund | One dispatch, one refund |
On other routes the header is ignored. The key is optional everywhere, but we strongly recommend it on checkout and anything that creates records.
Generate keys
- Use a random UUID (version 4) or a ULID:
crypto.randomUUID()in JavaScript,Str::uuid()in Laravel,uuidgenon the command line. - One key per action, not per request. Make the key when the shopper clicks "Pay" or "Add to basket", store it with that action, and send the same key on every retry of it.
- A new action gets a new key. If the shopper changes the basket and pays again, that is a new action.
- Do not derive keys from the body (such as a hash of the cart). Two genuine, identical actions, like adding the same mug twice, must have different keys.
Retry safely
async function postWithRetry(url: string, body: unknown, headers: Record<string, string>) {
const key = crypto.randomUUID() // made once, reused for every attempt
for (let attempt = 0; attempt < 5; attempt++) {
let waitMs = Math.min(8000, 250 * 2 ** attempt) + Math.random() * 250
try {
const res = await fetch(url, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const retryable = res.status === 409 || res.status === 429 || res.status >= 500
if (!retryable) return res
if (res.status === 429) waitMs = Number(res.headers.get('Retry-After') ?? 1) * 1000
if (res.status === 409) {
const { error } = await res.clone().json()
if (error.code !== 'IDEMPOTENCY_IN_PROGRESS') return res // a real conflict, not a retry
}
} catch {
// Network error or timeout: the request may or may not have run. Retrying is safe.
}
await new Promise((r) => setTimeout(r, waitMs))
}
throw new Error('Request failed after retries')
}- Retry network errors, timeouts,
5xx,429(afterRetry-After) and409 IDEMPOTENCY_IN_PROGRESS, with the same key and the same body. - Do not retry other
4xxanswers unchanged: fix the request or ask the shopper, and use a new key for the new attempt if the body changes. - Treat
Idempotent-Replayed: trueas success. It means the first attempt worked and its response was lost.
Pagination
Page through lists with page and perPage, read links and meta, use cursors on merchant lists, and sync CRM records incrementally with filter[updated_since].
Rate limits
The request budgets per store and per caller, the rate-limit headers, per-shopper limits with X-Client-IP, and how to back off on 429.