Skip to content
Elara One docs

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:

SituationAnswer
First time the key is seenThe 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 running409 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 different422 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

RouteWhy
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/completeExactly one order per checkout
POST /v1/checkout/paypal/captureExactly one capture and one order
POST /v1/forms/{handle}One submission, one lead
PUT /v1/consentOne consent change
POST /v1/marketing/confirmOne confirmation
POST /v1/chat/messagesOne message
POST /v1/merchant/crm/contacts, /companies, /tasks and /{type}/{id}/notesNo duplicate CRM records
POST /v1/merchant/orders/{reference}/dispatch, /offline-refundOne 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, uuidgen on 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

Retry with the same key
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 (after Retry-After) and 409 IDEMPOTENCY_IN_PROGRESS, with the same key and the same body.
  • Do not retry other 4xx answers unchanged: fix the request or ask the shopper, and use a new key for the new attempt if the body changes.
  • Treat Idempotent-Replayed: true as success. It means the first attempt worked and its response was lost.