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

# Idempotency

Network calls fail, dispatchers retry, and clients sometimes can't tell
whether a request landed on our side before the connection dropped.
Sending the same `POST /v0/settlement-requests` twice without protection
would create two settlement requests, debit your prefunded pool twice,
and surface two downstream payments to reconcile.

The **`Idempotency-Key`** header solves that. Generate a unique key per
logical operation; send it on the request. If the request reaches us
once, you get a `201 Created` and the settlement. If your dispatcher
retries the same key, we replay our original response — no duplicate
settlement, no duplicate downstream call, no surprise debit.

<Warning>
  **The `Idempotency-Key` header is recommended today and will be
  required in a future API version.**
</Warning>

## When to send it

Send `Idempotency-Key` on any **mutating** request you might retry. In
v1 that's `POST /v0/settlement-requests`. Other mutating endpoints
will adopt the same header in subsequent releases; the semantics will
not change.

You do **not** need it on `GET` calls (they're naturally idempotent)
or on `PUT /v0/settlement-requests/:id` (the URL already pins the
target — replays land on the same row).

## Picking a key

A good key is **unique per logical operation, stable across retries,
and free of customer data**.

* **Format**: 1–255 printable ASCII characters (`0x21`–`0x7E`). No
  spaces, no tabs, no newlines, no control characters, no non-ASCII.
  A missing or empty header means "no idempotency" — the request runs
  normally; we don't dedupe.
* **Recommended**: a freshly-generated **UUID v4** per operation. Most
  language standard libraries (Node `crypto.randomUUID()`, Python
  `uuid.uuid4()`, Go `uuid.NewString()`) ship a generator; nothing
  custom required.
* **What "logical operation" means**: one user click, one webhook fanout,
  one queue message. If your dispatcher decides to retry the same
  click after a timeout, that's the **same** logical operation — reuse
  the key. If the user clicks a second time five minutes later, that's
  a **new** logical operation — new key.

```bash cURL theme={null}
curl --request POST \
  --url https://api-sandbox.superbank.co/v0/settlement-requests \
  --header 'Content-Type: application/json' \
  --header 'X-Api-Key: YOUR_API_KEY' \
  --header 'Idempotency-Key: 4f54ba12-3c5e-4f7d-9a3a-7e21d9b06c8a' \
  --data '{
    "type": "STABLECOIN_TO_STABLECOIN",
    "payment_reason": "PERSONAL_TRANSFERS",
    "amount": 20,
    "external_id": "txn_abc123",
    "destination": { "...": "..." }
  }'
```

## How replay works

The interceptor hashes the request body with **JCS (RFC 8785)
canonical JSON** — recursively sorting object keys before hashing —
and stores `(api_key_id, key, request_hash) → (status, response_body)`
on the first successful (or client-error) response.

A retry with the **same key**:

* **Same body** (after JCS normalization) → we replay the original
  response verbatim. The HTTP status and body match the first call
  exactly. The response carries an `Idempotent-Replayed: true` header
  so your code can tell a replay from a fresh execution.
* **Different body** → we reject with **`400 idempotency_mismatch`**.
  This catches dispatcher bugs where the same key is reused across
  unrelated operations.
* **Concurrent retry while the first is still in flight** → we reject
  with **`409 idempotency_conflict`** + a `Retry-After: 1` header.
  Wait one second and retry.

<Tip>
  **Key order doesn't matter.** We canonicalize the JSON before
  hashing, so `{"amount": 20, "type": "..."}` and
  `{"type": "...", "amount": 20}` are treated as the same body. JSON
  serializers in many languages don't guarantee key order, and
  exact-bytes comparison would produce false `idempotency_mismatch`
  rejections on otherwise-identical retries. Canonicalization
  eliminates that class of bug.
</Tip>

## Retention and scope

| Property                | Value                               |
| ----------------------- | ----------------------------------- |
| **Retention window**    | **24 hours** from the first attempt |
| **Scope**               | **Per API key** (not per developer) |
| **Body comparison**     | JCS-canonicalized + SHA-256 hash    |
| **Response cached for** | 2xx and 4xx responses               |
| **Not cached**          | 5xx responses (re-runnable)         |

**Retention.** A key is retained for 24 hours after the first call
lands on the API. Retries within the window replay; retries past the
window execute fresh and may create a new settlement.

**Scope.** Each API key tracks its own keys. Two dispatchers using
different API keys from the same developer can use overlapping
`Idempotency-Key` values without collision — they're treated as
genuinely different callers.

**5xx is not cached.** If our side returns a `500` (or higher), we
delete the in-flight idempotency row before the response goes out.
The next retry executes fresh — you do not get stuck replaying an
error you can't recover from.

## Error responses

### `409 Conflict` — `idempotency_conflict`

A concurrent request with the same key is still in flight. Wait the
duration suggested by `Retry-After`, then retry.

```json theme={null}
{
  "statusCode": 409,
  "error": "idempotency_conflict",
  "message": "A request with this Idempotency-Key is still in flight"
}
```

### `400 Bad Request` — `idempotency_mismatch`

The same key was reused with a different request body (post-
canonicalization). Common root causes: two unrelated operations sharing
a key, or the payload mutating between attempts of the same operation.

```json theme={null}
{
  "statusCode": 400,
  "error": "idempotency_mismatch",
  "message": "Idempotency-Key was reused with a different request body"
}
```

### `400 Bad Request` — `invalid_idempotency_key`

The header was sent but the value fails the format check: longer than
255 characters, or contains characters outside printable ASCII
(`0x21`–`0x7E`) — spaces, tabs, newlines, control characters, or any
non-ASCII byte. A missing or empty header does **not** trigger this
error; it simply skips idempotency and runs the request normally.

```json theme={null}
{
  "statusCode": 400,
  "error": "invalid_idempotency_key",
  "message": "Idempotency-Key must be 1..255 printable ASCII characters"
}
```

## Replay example

First call lands and creates settlement `39760060-…`:

```bash theme={null}
$ curl -i -X POST .../v0/settlement-requests \
    -H "Idempotency-Key: 4f54ba12-3c5e-4f7d-9a3a-7e21d9b06c8a" \
    -d '{...}'

HTTP/1.1 201 Created
content-type: application/json

{ "id": "39760060-846e-4d5a-8583-7ee62553f79b", "status": "REQUEST_STARTED", "..." }
```

Same key, same body — replays the original:

```bash theme={null}
$ curl -i -X POST .../v0/settlement-requests \
    -H "Idempotency-Key: 4f54ba12-3c5e-4f7d-9a3a-7e21d9b06c8a" \
    -d '{...}'

HTTP/1.1 201 Created
content-type: application/json
idempotent-replayed: true

{ "id": "39760060-846e-4d5a-8583-7ee62553f79b", "status": "REQUEST_STARTED", "..." }
```

Same id. The settlement was created **once**.

## What's idempotent on our side, automatically

The interceptor covers the request/response layer. Beyond that,
Superbank's internal pipeline also derives a deterministic key from
your `settlementId` for every downstream provider call — so even
without your header on the Superbank request, a retry that *does*
manage to create two settlements still produces only one downstream
payment per settlement. The header you send protects the **first**
layer (prevents duplicate settlements); our internal deduplication
protects the **second** (prevents duplicate provider activity per
settlement).

## Recommended dispatcher pattern

1. Generate a UUID v4 when you accept the user request (or pull a
   queue message).
2. Persist the key alongside the in-flight operation in your own
   store.
3. Send the key on every retry of that same operation.
4. On `201` (with or without `Idempotent-Replayed`), record the
   Superbank `id` against your operation and stop retrying.
5. On `4xx` other than `idempotency_conflict`: the request failed
   validation; replaying with the same payload will produce the same
   error. Surface it to your client.
6. On `5xx` or `409 idempotency_conflict`: retry with the **same**
   key after a short backoff. The 24-hour retention window covers
   any realistic retry chain.

## Next steps

<Card title="Settlement Requests" icon="link" href="/platform/core-resources/settlement-request">
  Full reference for the `POST /v0/settlement-requests` body, including
  the `external_id` and `metadata` correlation fields you'll likely
  pair with `Idempotency-Key`.
</Card>

<Card title="Webhooks" icon="link" href="/platform/guides/webhook-guide">
  Outbound webhooks also include `external_id` and `metadata` so you
  can match `settlement_request.updated` events back to your
  idempotent operation without parsing the body twice.
</Card>
