Skip to main content

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​

HTTP
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.

RouteSame key, same bodySame key, different body
POST /v1/banking/payouts200 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/execute409 conflict, whose message names the conversion and the status of the first attempt. Not executed again.409 idempotency_key_reuse
POST /v1/banking/accounts409 conflict — "you already hold a … account"Same, if that currency is held
POST /v1/banking/beneficiariesSandbox: 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/accountsRequired, 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 needed409 idempotency_key_reuse
POST /v1/sandbox/banking/accounts/{id}/simulate-depositThe same amount on the same network is the same deposit, for 24 hours — not deposited twiceA 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 again409 idempotency_key_reuse
POST /v1/sandbox/banking/payouts/{id}/simulate-arrival, …/simulate-completionNo 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 as 200 with status: "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 to submitted, settled or failed. If that attempt ended in a 4xx, nothing was sent: send again with a new key. If it ended in a 5xx or a timeout, look the payout up and wait for it to leave created; failed means 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 messageSandboxProduction
settledThe conversion happenedIt happened
failedIt was refused; nothing movedIt 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.