Idempotency
A request that moves money can fail in the worst possible place: after we did
the work and before you heard about it. The Idempotency-Key header is how a
retry of that request finds the first attempt instead of repeating it.
The header
Idempotency-Key: payout-INV-0042
- 1 to 255 visible ASCII characters, trimmed. Any format — a UUID is fine, but so is a meaningful id.
- Missing, empty, too long or with invalid bytes:
400 bad_request, for example "Idempotency-Key header is required on write endpoints". - It is checked before the body is read, so a missing key is reported even when the body is also wrong.
Choose the key from your own record of the operation — the invoice, the payment run, the ledger entry — and store it with that record. Then every retry, from any process, after any crash, sends the same key. A key generated fresh on each attempt protects nothing.
Where it is required, and what a retry returns
Behaviour is not the same on every route. The difference that matters is what the second request returns.
| Route | Same key, same body | Same key, different body |
|---|---|---|
POST /v1/banking/payouts | 200 with the payout as it is now — settled, failed, whatever it has become. Never a second payout, and never a re-send of the first. | 409 idempotency_key_reuse |
POST /v1/banking/fx/execute | 409 conflict, whose message names the conversion and the status of the first attempt. Not executed again. | 409 idempotency_key_reuse |
POST /v1/banking/accounts | 409 conflict — "you already hold a … account" | Same, if that currency is held |
POST /v1/banking/beneficiaries | Sandbox: the same beneficiary. Production: passed to the partner, which does not honour it — a retry can register a duplicate. | Not checked |
POST /v1/collect/payment-links, POST /v1/collect/payments/{id}/refunds (dashboard routes) | Passed to our card processor as its idempotency key | — |
POST /v1/accounts, POST /v1/customers, POST /v1/collect/accounts | Required, but not used: these routes are idempotent on the email or the account instead | — |
POST /v1/banking/kyb/digital-assets (sandbox) | The same request repeated: the verification under way is served again | — |
POST /v1/banking/digital-assets/wallets (sandbox) | 200 with the wallet you hold on that chain — a new key never opens a second one either | — |
POST /v1/banking/digital-assets/trusted-beneficiaries (sandbox) | 200 with the entry, no step-up needed | 409 idempotency_key_reuse |
POST /v1/sandbox/banking/accounts/{id}/simulate-deposit | The same amount on the same network is the same deposit, for 24 hours — not deposited twice | A different amount is a different deposit |
POST /v1/sandbox/banking/faucet (optional) | 200 with the same currency and amount and the balance as it stands — credited once. Without a key, every call credits again | 409 idempotency_key_reuse |
POST /v1/sandbox/banking/payouts/{id}/simulate-arrival, …/simulate-completion | No key: the payout is the key. Asking again does nothing twice | — |
A send from a digital-asset account follows the payout row above, and adds
details.idempotency_key_spent to tell whether a refusal used the key up —
see Digital-asset errors.
Every other write — quotes, webhooks, keys, other KYB routes, contacts, account close, statements — takes no key. Quotes move nothing, so a repeated quote is just another quote.
No response header marks a replay. Compare the payout's id and read its
status.
Payouts: read the status of a replay
A replay answers 200 with the payout's current state, so the HTTP code alone
does not tell you whether money moved:
- A first attempt that was refused after it was recorded — in the sandbox,
400 insufficient_balance— replays as200withstatus: "failed". Fix the cause and send again with a new key. - A replay with
status: "created"means the outcome of the first attempt is not known yet: it is still running, or the call to the bank failed or timed out after the payout was recorded. A replay will never send it; a reconciler matches it against the bank's own notifications and moves it tosubmitted,settledorfailed. If that attempt ended in a4xx, nothing was sent: send again with a new key. If it ended in a5xxor a timeout, look the payout up and wait for it to leavecreated;failedmeans the bank never took it. See Payouts. - A replay runs the ownership, verification and lockdown checks again — and in production the payee-name check — before it finds the payout, so it can be refused where the first attempt was not.
FX: a 409 conflict names the first attempt
If a conversion request timed out and your retry gets
409 conflict — "this Idempotency-Key was already used for conversion
6f1e… (status: settled)" — the first attempt was recorded. What the status
means:
status in the message | Sandbox | Production |
|---|---|---|
settled | The conversion happened | It happened |
failed | It was refused; nothing moved | It failed, as the partner reported |
submitted | — | The partner accepted it; it settles later |
created | — | Not known yet: the first attempt is still running, or the partner call failed or timed out. The reconciler resolves it from the partner's notifications — fetch it until it leaves created before converting again. |
Fetch the conversion with GET /v1/banking/payouts/{id}. One exception: a
conversion of a quote we hold no record of — rare, and possible only with an
API key — is not recorded, so its key protects nothing: a retry reaches the
partner again.
Scope and lifetime
- Keys are scoped to the account the request acts for — your own, or the
managed account named in
Cxc-Account. They are not scoped to an API key: two keys of the same account share one keyspace. - Payouts and conversions share one keyspace. A key used for a payout
cannot be reused for a conversion, and the other way round — that is a
409 idempotency_key_reuse. - A key used for a payout or conversion never expires: it stays bound to that transaction.