Idempotency
A request that takes money must be safe to send twice, because a network that drops the reply after the money moved is not a rare event — it is Tuesday. The Idempotency-Key header is how you say "this is the same request as before".
Idempotency-Key: order-20260916-004178 to 128 characters of A–Z a–z 0–9 - _. Derive it from your own order or sale id, so a retry of the same order carries the same key by construction, and store it with the order.
What the key promises
- The same key with the same request returns the same object. A retry of
POST /v1/chargesreturns the charge made the first time, with the sameid, and does not prompt the payer again. - The same key with a different request is refused with
IDEMPOTENT_MISMATCH(409), and nothing is created. The first request under that key stands. - A retry while the first attempt is still being written waits for it and then gets the same answer. There is no "in flight" error to handle.
- Keys are scoped to your business. Two businesses may use the same string.
"The same request" means the fields that decide who is asked for how much: amount_minor, currency, rail, msisdn and reference on a charge; till, amount_minor, reference and description on a POS charge. A retry that fixed a typo in description on a charge is the same charge. Do not rely on the edges of that; send the identical body.
Where it is required
| Route | Idempotency-Key |
|---|---|
POST /v1/charges | Required. Missing or malformed is IDEMPOTENCY_REQUIRED. |
POST /v1/pos/charges | Required. |
POST /v1/payment_links | Optional. Honoured when sent. |
POST /v1/bills | Not accepted. A duplicate bill is a second slip you can see and cancel. |
The sandbox requires it exactly where production does, so your retry path can be tested against the sandbox. Test it: send a charge, send it again, and assert the id is the same.
Retrying
LEDGER_UNAVAILABLE (503) and RATE_LIMITED (429) mean nothing was taken; send the same request with the same key. Every other code means something in the request has to change, or that a person has to act. Each error page says which.
A sensible client retries 503 and 429 with backoff and the same key, up to a handful of times, and then surfaces the failure. It never retries a 4xx unchanged.
A pattern
js
async function charge(order) {
const res = await fetch(`${BASE}/v1/charges`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Idempotency-Key": `order-${order.id}`, // same order, same key, for ever
"Content-Type": "application/json",
},
body: JSON.stringify({ amount_minor: order.totalMinor, rail: order.rail, msisdn: order.phone, reference: order.id }),
});
if (res.status === 503 || res.status === 429) throw new Retryable(await res.json());
if (!res.ok) throw new Refused(await res.json());
return res.json();
}