Skip to content

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-00417

8 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/charges returns the charge made the first time, with the same id, 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

RouteIdempotency-Key
POST /v1/chargesRequired. Missing or malformed is IDEMPOTENCY_REQUIRED.
POST /v1/pos/chargesRequired.
POST /v1/payment_linksOptional. Honoured when sent.
POST /v1/billsNot 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();
}

Every code, scope and route on this site is rendered from the API's own source.