Send an Idempotency-Key so a retry can never create a duplicate payment, payee, or card.
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.
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.
200 with the original payment, beneficiary, or card. Nothing is created twice.
Retry while the first request is still running
409 with code: IDEMPOTENT_REQUEST_IN_PROGRESS. Wait a moment and retry — don’t strip the key and resend.
Same key, different parameters
400 — Idempotency-Key was already used with different parameters. Reusing a key for a different operation is a bug, so we reject it rather than return a stale result.
Retry after the original attempt failed
A FAILED payment is re-run under the same key — see below.
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.
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.
// Mint once, when the user commits to the action.// crypto.randomUUID() needs a secure context (https/localhost) in the browser,// and Node 19+. On older Node: require("crypto").randomUUID().const idempotencyKey = crypto.randomUUID();const backoff = (attempt) => new Promise((r) => setTimeout(r, 500 * attempt));// How long to keep retrying before giving up. Five attempts is ~5s of backoff.const MAX_ATTEMPTS = 5;async function sendPayment(attempt = 1) { let res; try { res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Idempotency-Key": idempotencyKey, // same key on every attempt "Content-Type": "application/json" }, body: JSON.stringify(payment) // same parameters on every attempt }); } catch (networkError) { // The case this whole page exists for: we never saw a response, so we don't // know whether the payment went out. The key makes resending safe. if (attempt < MAX_ATTEMPTS) { await backoff(attempt); return sendPayment(attempt + 1); } throw networkError; } // A gateway can return HTML, so don't assume the body parses. const result = await res.json().catch(() => null); // Retry the two transient cases with the SAME key: the server hasn't given a // final answer (5xx), or another attempt is still mid-flight. Everything else // is final — IDEMPOTENCY_KEY_CONFLICT in particular is permanent, so retrying // it just repeats the same error. const transient = res.status >= 500 || (res.status === 409 && result?.code === "IDEMPOTENT_REQUEST_IN_PROGRESS"); if (transient && attempt < MAX_ATTEMPTS) { await backoff(attempt); return sendPayment(attempt + 1); } return result;}
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.