Networks fail in the worst possible way: your request arrives, we process it, and the response is lost on the way back. You don’t know whether the payment went out. Retry and you might send it twice; don’t retry and you might not have sent it at all. An idempotency key removes that ambiguity. You pick a key, send it with the request, and reuse the same key on every retry of that same operation. We guarantee the operation happens at most once — a retry returns the original result instead of creating a second payment, payee, or card.
Optional today, required later. The Idempotency-Key header is optional right now: requests without one behave exactly as they always have. We intend to enforce it on all money-movement and resource-creating endpoints in a future release, so requests without a key will start being rejected.Send a key on every create from now on. Adding it later is a migration; adding it today is one line, and it protects you against duplicates in the meantime.

How to send it

Pass the key in the Idempotency-Key request header. It must be a UUID.
Every endpoint in the table below also accepts the key as an idempotencyKey field in the JSON body, for parity with batch payments. If you send both, the header wins. Prefer the header — it’s the same on every endpoint and it doesn’t interact with the request body.
Generate one key per logical operation, not per attempt. Mint the UUID when the user taps “Send”, store it alongside the operation, and reuse it for every retry — including retries after a timeout, a 5xx, or an app restart. A fresh key on each attempt gives you no protection at all.

Where it’s supported

Read-only endpoints (GET) and endpoints that change existing state (block, cancel, activate) don’t take a key: repeating them doesn’t create anything.

What happens on a retry

Two details that matter if you’re writing retry logic against this: “Different parameters” means the core ones, and which ones depends on the endpoint: Anything outside that set is not compared, so changing only those fields on a retry replays the original instead of returning a 400. On an international transfer, for example, chargeBearer and debtorViban are outside it.
quoteId is compared on international transfers. FX quotes expire, so the natural retry — refresh the quote, resend — changes a compared field and gets a 400, not a replay. A refreshed quote is a new transfer: mint a new key for it.
The safe rule is simpler than the table: if you’ve changed anything about a request, use a new key. A failed original doesn’t block a retry. A FAILED payment is re-run: it isn’t a result worth replaying, so the same key starts a fresh attempt. The exception is a payment held for a banking-limit review that the customer cancelled or compliance rejected. That FAILED is a decision, not a fault, so the key is closed: a retry gets 409 LIMIT_REVIEW_CLOSED and nothing is sent. If the customer still wants to pay, that is a new payment with a new key. 409 IDEMPOTENT_REQUEST_IN_PROGRESS applies to payments, card creation and batch submit. Adding a beneficiary is a single synchronous write, so it has no in-progress state — those operations never return a 409. There is also a 409 IDEMPOTENCY_KEY_CONFLICT, raised if a key ever resolves to another customer’s resource. In practice you should never see it: keys are customer-scoped (below), so two customers picking the same UUID get their own resources. It exists as a safety net, not as a case you need to handle.
A batch key is not a replay key. Unlike the single-resource creates, a second submit never returns the first batch.
  • Paying a draft (the /submit routes, or any route called with batchId): the draft’s batchId fixes the batch and its item ids, not the key. A second submit of the same draft gets 409 IDEMPOTENT_REQUEST_IN_PROGRESS while the first is still running, whether or not a key is sent, and 400 (Batch <batchId> is no longer a draft awaiting submission) once an earlier submit has gone through.
  • The older single endpoint called without batchId (POST /v1/customers/{customerId}/batch-payments): the batch’s identity comes from the key, so submitting twice with the same idempotencyKey re-runs the submission against the same batchId. While the first submit is still running the second gets 409 IDEMPOTENT_REQUEST_IN_PROGRESS; after that, the re-run goes out on the funds the first attempt still holds and doesn’t run an item twice. Parameters aren’t compared, though, so a reused key with a different list of payments is not rejected: its rows are matched to the first batch’s by position, not by content.
Treat submit as a one-shot call: if a response is lost, track the batch to find out whether it landed. Never resend.

Scope of a key

On the single-resource creates (payments, beneficiaries, cards), keys are scoped to the authenticated customer, so two customers can pick the same UUID without colliding.
Batch payments are the exception. A batch key is not scoped to the customer, so it must be unique across your whole integration — not just per customer. Generate a fresh UUID per submission (never a constant, a template value, or a reused test fixture). On the older single endpoint called without batchId, a batch’s identity is derived from the idempotencyKey alone: two submissions that share a key land on the same batchId, and the second is treated as a re-run of the first — its rows are matched to the first batch’s by position, so what goes out is not what the second submission sent. When you pay a draft (the /submit routes), the draft’s batchId identifies the batch instead.
A key never weakens the first request’s step-up. When you add a payee, the confirmation is verified before the replay is resolved, so a retry still has to prove it’s the customer.On payments, a replay short-circuits earlier: once a payment has been dispatched under a key, a retry with that key returns the original result without re-running the step-up. The original payment was fully authorised — the replay just hands you back its result, so a lost response doesn’t cost your customer a second confirmation prompt.

Putting it together

The two rules that make this work: the same key and the same parameters on every attempt. Change either and you’re making a new request, not retrying the old one.