# Idempotency

Networks drop connections in the middle of payouts. The API is built so that the correct
response to that is always the same: **retry with the same key**.

## The key

Every mutation that moves money accepts an `idempotency_key` in the request body — a string
you generate, 1–255 characters, unique per merchant per logical operation.

| Endpoint | Summary |
| --- | --- |
| [`POST /v1/withdrawals`](withdrawals#post-v1-withdrawals) | Request a payout |
| [`POST /v1/exchange/orders`](exchange#post-v1-exchange-orders) | Start an exchange — sell a held coin, pay another one out |
| [`POST /v1/swap/orders`](swaps#post-v1-swap-orders) | Start a swap — sell one coin the user holds, credit them another |
| [`POST /v1/cards`](cards#post-v1-cards) | Issue a card |
| [`POST /v1/cards/{id}/topup`](cards#post-v1-cards-id-topup) | Top up a card |
| [`POST /v1/cards/{id}/withdraw`](cards#post-v1-cards-id-withdraw) | Retired — moving part of a card balance back to the wallet |

It is **required** on withdrawals and the three card money mutations (issue,
top-up, withdraw). It is **optional but strongly recommended** on swap and exchange
orders — optional only because those rails were added later, not because replay safety
matters less there.

A UUID v4 per logical operation is the right default. Deriving it from your own primary key
— `payout-8814`, `card-topup-2291` — is better still, because it survives a process restart
that a freshly generated UUID would not.

> **Important** "Per logical operation" means per *intended movement of money*, not per HTTP
> attempt. Generate the key once, store it next to the record that motivated the payout, and
> reuse it for every retry of that payout — including retries days later.

## What a replay returns

A replay with the same key and the same parameters returns **the original record,
unchanged**. No second hold, no second payout, no second order.

The status code follows the resource:

| Endpoint | First call | Replay |
| --- | --- | --- |
| `POST /v1/withdrawals` | `202` | `202`, with the original withdrawal |
| `POST /v1/cards`, `/topup`, `/withdraw` | `201` / `202` | same code, original order |
| `POST /v1/swap/orders`, `/v1/exchange/orders` | `201` | `201`, with the original order |

The body is the record as it stands *now*, not as it stood when it was created — so a
replay of a withdrawal that has since confirmed returns it with `status: confirmed`. That
is exactly what you want: the replay doubles as a status read.

## Reusing a key for different parameters is an error

On withdrawals, card mutations, swaps and exchanges, if the key matches an
existing record but the parameters do not, the API refuses rather than guessing:

```json
{
  "error": "idempotency_conflict",
  "message": "idempotency key payout-8814 was already used with different parameters"
}
```

with HTTP `409`. It will never silently return the original record for a request that asked
for something else, and it will never quietly create a second one. If you see this, your key
generation is colliding — two different payouts are being assigned the same key.

"The same parameters" means the fields that describe the movement. For a withdrawal that
is the user, the asset, the address and the amount; the quoted fees are not compared,
because they may legitimately differ between genuine retries of one payout.

## Resolving an indeterminate request

The hard case is not a rejection. It is a create that **timed out** — you sent it, the
connection dropped, and you do not know whether a payout exists.

There are two safe moves, and they agree with each other.

**Re-POST with the same key.** This is safe by construction. Either it creates the payout
(it never landed) or it returns the existing one (it did). One call, one answer.

**Or look the key up.** `GET /v1/withdrawals?idempotency_key=…` is the read path built for
exactly this: it returns the withdrawal for that key, or `404` if no withdrawal was ever
created. Use this when
you would rather not issue a write at all until you know the state.

What you must **not** do is retry with a *fresh* key. That is how a customer gets paid
twice.

## Cards: what 202 means, and what it does not

The card money endpoints return `202 Accepted`. The ledger move has happened; the
*provider* outcome has not been confirmed yet. It arrives later as a `card.topup` /
`card.withdraw` webhook, or through the reconciler.

**Treat `202` as pending, never as failure.** Refund your user only on a *definitive*
failure:

- a `409 provider_rejected` at request time, or
- a webhook whose order `status` is `failed`.

A top-up's charge is refunded automatically on definitive failure. A withdrawal credits the
user only once the provider confirms, and nothing is held meanwhile. Issuance behaves the
same way: the `issue_fee` is charged at request time and auto-refunded if the application
definitively fails.

## Retry policy that works

1. Generate the key **before** the first attempt and persist it with your own record.
2. On a timeout, a `5xx`, or a dropped connection: retry with the **same** key, backing off.
3. On a `4xx` other than `409 idempotency_conflict`: do not retry — fix the request.
4. On `409 idempotency_conflict`: do not retry — you have a key collision to fix.
5. When you stop retrying, resolve the state with the `idempotency_key` lookup or the
   resource read before you tell anyone anything final.

Idempotency keys are scoped to your merchant, so they never collide with another
merchant's. They do not expire.
