> ## Documentation Index
> Fetch the complete documentation index at: https://docs-consumer.voucher.allride.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> Retry POST requests safely with the Idempotency-Key header: how replays work, key scope across key rolls, and a retry strategy.

Networks fail. A timeout after you sent `POST /consumer/v1/redemptions` does not tell you whether the voucher was redeemed. The `Idempotency-Key` header makes such a retry safe: the platform runs the request once and answers every retry with the stored response.

## When to use idempotency keys

* **Every `POST` of every surface** accepts the optional `Idempotency-Key` header: creates, commands such as `:void` and `:revoke`, and batches.
* Send one on every `POST` that has an effect you must not repeat. For redemptions it is essential: without a key, a retry after a timeout can redeem a second voucher.
* Without a key the request is processed normally, every time.
* `GET`, `PUT`, `PATCH` and `DELETE` ignore the header; the response then carries `Idempotency-Key-Ignored: true`. `PUT`, `PATCH` and `DELETE` are naturally repeatable; see [Concurrent changes](https://docs-consumer.voucher.allride.io/guides/requests-and-responses.md#concurrent-changes).

## Choosing a key

* A key is 1 to 255 printable ASCII characters (`!` to `~`), without spaces. An empty key, a longer one, or one with a space or a non-ASCII character is answered with `400` [`invalid-idempotency-key`](https://docs-consumer.voucher.allride.io/errors/catalog#invalid-idempotency-key).
* Use a UUID (v4 or v7) or a deterministic business key such as `order-8812:line-1`.
* Use **one key per intent** (one customer action, one order line) and reuse it for every retry of that intent. Never reuse a key for a different request.
* Never put personal data into a key: keys are stored and can be read back with the redemption.

```bash theme={null}
curl -X POST "$VOUCHER_API_BASE_URL/consumer/v1/redemptions" \
  -H "Authorization: Bearer $VOUCHER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8812:line-1" \
  -d '{"voucherSetId": 2030, "redemptionEntity": {"refId": "customer:48213"}}'
```

## How replays work

The platform fingerprints each request: method, route, path parameters, query parameters and the canonical JSON body. Key order and whitespace in the body do not matter; numeric literals do (`1.0` and `1` are different bodies), so resend identical values.

| You send | You get |
| - | - |
| A new key | Normal processing; the outcome is stored for 24 hours |
| The same key and the same request | The stored response, with `Idempotent-Replayed: true`. Nothing runs again: no second redemption, no audit entry, no webhook |
| The same key and a different request | `422` [`idempotency-key-reused`](https://docs-consumer.voucher.allride.io/errors/catalog#idempotency-key-reused) |
| The same key while the first request is still running | `409` [`idempotency-key-in-use`](https://docs-consumer.voucher.allride.io/errors/catalog#idempotency-key-in-use) with `Retry-After: 1` |

**Stored outcomes.** Successful responses (`2xx`) and final client errors are stored and replayed: `400` (`malformed-json`, `invalid-header`, `data-failed-validation`), `404`, domain `409` conflicts, `410` and `422`. A stored domain `409` such as [`voucher-set-depleted`](https://docs-consumer.voucher.allride.io/errors/catalog#voucher-set-depleted) is **final for that key**: a retry with the same key replays the `409` even after new stock arrived. A new attempt needs a **new key**.

**Not stored** (a retry with the same key is safe and runs again): `409` [`concurrent-update`](https://docs-consumer.voucher.allride.io/errors/catalog#concurrent-update), `409 idempotency-key-in-use`, `422 idempotency-key-reused`, every `5xx`, timeouts and network failures. `401` and `403` are decided before the key is claimed and are never stored.

**Show-once values.** Responses that contain a secret shown only once (API key create and roll, webhook endpoint create and secret rotation, the SEPA setup `clientSecret`) are stored with that secret set to `null`. A replay returns `null` there and carries `Idempotent-Replay-Redacted: true`. If you lost the original response, revoke the key or rotate the secret and create a new one.

**Issued vouchers replay in full.** A redemption's `issued.code` and `issued.link` are not show-once: a replay of `POST /consumer/v1/redemptions` or `:batch` returns the identical body, issued values included.

**Asynchronous jobs.** A replayed `202` shows the job as it was when it was accepted. Follow `Location` for its current state.

Keys are kept for **24 hours**. After that the same key executes again, which is another reason never to reuse keys across intents.

## Key scope and key rotation

Keys are scoped to the caller:

* For API keys, the scope is the API key's **roll chain**. A key created with [`:roll`](https://docs-consumer.voucher.allride.io/api-reference/partner-api-keys-roll) shares the scope of the key it replaces, so retrying an intent through the rolled key with the same `Idempotency-Key` **replays**: there is no second effect.
* An **independently created** API key has its own scope. Retrying an intent through it with the same `Idempotency-Key` is **not** a replay and executes again; for a redemption that means a second voucher.
* A dashboard user's keys are scoped to that user and partner.

Rule for integrations: rotate API keys with `:roll`, never by creating a fresh key and switching over in the middle of an intent. If you did switch to an independent key, or lost track of an intent, reconcile before you retry (see below).

## Errors

| Status and code | Meaning | What to do |
| - | - | - |
| `400` [`invalid-idempotency-key`](https://docs-consumer.voucher.allride.io/errors/catalog#invalid-idempotency-key) | The key is malformed | Send 1-255 printable ASCII characters, no spaces |
| `409` [`idempotency-key-in-use`](https://docs-consumer.voucher.allride.io/errors/catalog#idempotency-key-in-use) | The first request with this key is still running | Wait `Retry-After` seconds, retry with the **same** key |
| `422` [`idempotency-key-reused`](https://docs-consumer.voucher.allride.io/errors/catalog#idempotency-key-reused) | The key was used for a different request | Fix your key generation; use a new key for a new request |

## Retry strategy

Retry with the **same** key on network errors, timeouts, `409 idempotency-key-in-use`, `409 concurrent-update` and `5xx`, with exponential backoff and jitter, honouring `Retry-After`. Do not retry other `4xx` responses without changing the request; when you change it, use a new key.

```ts theme={null}
async function postIdempotent(path: string, body: unknown, key = crypto.randomUUID()): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${process.env.VOUCHER_API_BASE_URL}${path}`, {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.VOUCHER_API_KEY}`,
                 "Content-Type": "application/json", "Idempotency-Key": key },
      body: JSON.stringify(body),
    }).catch(() => undefined);                       // network error: retry with the same key
    if (res && res.status < 500 && res.status !== 409 && res.status !== 429) return res;
    if (res?.status === 409) {                       // only transient 409s are retryable with the same key
      const { errors } = await res.clone().json();
      if (!["idempotency-key-in-use", "concurrent-update"].includes(errors?.[0]?.errorCode)) return res;
    }
    const retryAfter = Number(res?.headers.get("Retry-After") ?? 0);
    await new Promise(r => setTimeout(r, Math.max(retryAfter * 1000, 2 ** attempt * 250 + Math.random() * 250)));
  }
  throw new Error("Gave up after 5 attempts; the same Idempotency-Key is still safe to retry later (24 h)");
}
```

Persist the key together with the intent (for example on your order line) before the first attempt, so a retry after a crash of your own service uses the same key.

## Where to use it in the Consumer API

Send a key on every:

* [`POST /consumer/v1/redemptions`](https://docs-consumer.voucher.allride.io/api-reference/consumer-redemptions-create) and [`POST /consumer/v1/redemptions:batch`](https://docs-consumer.voucher.allride.io/api-reference/consumer-redemptions-batch) (one key covers the whole batch);
* [`POST /consumer/v1/redemptions/{redemptionId}:void`](https://docs-consumer.voucher.allride.io/api-reference/consumer-redemptions-void);
* [`POST /consumer/v1/redemption-entities`](https://docs-consumer.voucher.allride.io/api-reference/consumer-redemption-entities-create) and the entity commands;
* [`POST /consumer/v1/redemption-links`](https://docs-consumer.voucher.allride.io/api-reference/consumer-redemption-links-create) and [`POST /consumer/v1/redemption-links:batch`](https://docs-consumer.voucher.allride.io/api-reference/consumer-redemption-links-batch);
* every workspace `POST`, for example [create a webhook endpoint](https://docs-consumer.voucher.allride.io/api-reference/partner-webhook-endpoints-create.md).

**Reconciling a redemption intent.** Every redemption stores the `Idempotency-Key` it was created with (a batch stores its key on each of its redemptions). If you are unsure whether an intent ran, for example after moving to an independently created API key, look it up:

```bash theme={null}
curl -G "$VOUCHER_API_BASE_URL/consumer/v1/redemptions" \
  -H "Authorization: Bearer $VOUCHER_API_KEY" \
  --data-urlencode "filter[idempotencyKey]=order-8812:line-1"
```

* A **non-empty page** means the intent already ran: use those redemptions and do not redeem again.
* An **empty page** means it did not run: retry with the same key.

A `409 price-acceptance-required` or `409 voucher-set-depleted` is stored for its key. After you accepted the price or new stock arrived, redeem again with a **new** key.

## Related

* [Redeeming vouchers](https://docs-consumer.voucher.allride.io/consumer/redeeming.md)
* [Errors](https://docs-consumer.voucher.allride.io/errors.md)
* [API keys](https://docs-consumer.voucher.allride.io/guides/api-keys.md)
* [Requests and responses](https://docs-consumer.voucher.allride.io/guides/requests-and-responses.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.