# Idempotency keys

URL: /docs/concepts/idempotency

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 [#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.

```bash tab="cURL"
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…" } }'
```

```ts tab="TypeScript"
const key = crypto.randomUUID()

const res = await fetch('https://api.elaraone.app/api/v1/cart/lines', {
  method: 'POST',
  headers: {
    'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
    'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
    'X-Cart-Token': cartToken,
    'Idempotency-Key': key,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ variant_id: 1543, quantity: 1 }),
})

const replayed = res.headers.get('Idempotent-Replayed') === 'true'
```

```php tab="PHP"
use Illuminate\Support\Str;

$key = (string) Str::uuid();

$response = Http::withHeaders([
    'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'),
    'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'),
    'X-Cart-Token' => $cartToken,
    'Idempotency-Key' => $key,
])->post('https://api.elaraone.app/api/v1/cart/lines', ['variant_id' => 1543, 'quantity' => 1]);
```

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 [#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 [#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 [#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 [#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-safely]

```ts title="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.

<Cards>
  <Card title="Checkout" href="/docs/concepts/checkout" description="Place an order exactly once." />
  <Card title="Errors" href="/docs/concepts/errors" description="Which errors to retry, and how." />
</Cards>
