#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 | Request a payout |
POST /v1/exchange/orders | Start an exchange — sell a held coin, pay another one out |
POST /v1/swap/orders | Start a swap — sell one coin the user holds, credit them another |
POST /v1/cards | Issue a card |
POST /v1/cards/{id}/topup | Top up a card |
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.
#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:
{
"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_rejectedat request time, or - a webhook whose order
statusisfailed.
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
- Generate the key before the first attempt and persist it with your own record.
- On a timeout, a
5xx, or a dropped connection: retry with the same key, backing off. - On a
4xxother than409 idempotency_conflict: do not retry — fix the request. - On
409 idempotency_conflict: do not retry — you have a key collision to fix. - When you stop retrying, resolve the state with the
idempotency_keylookup 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.