Idempotency

Required on every mutating request, and the one mechanism that survives a timeout.

Every mutating request must carry a key:

Idempotency-Key: 5f1c3d9e-0b47-4c2a-9d3e-6a1b8c2f4e77

A UUID per logical operation. Not per retry — the whole point is that a retry sends the same key.

What it buys you

Your request times out. The connection dropped somewhere between your process and ours. You do not know whether the payment was created.

Retry with the same key and you get the same answer: the stored response from the first attempt, byte for byte, with the same status code. No second payment.

Without this there is no safe answer to "did that go through", because the only way to find out — creating it again — is the thing you are trying to avoid.

The rules

Getting it wrong

The common bug is generating the key inside the retry loop:

// WRONG — every attempt is a new operation
for (let i = 0; i < 3; i++) {
  await post('/v1/payments', body, { 'Idempotency-Key': crypto.randomUUID() });
}
// RIGHT — one key for the operation, reused by every attempt
const key = crypto.randomUUID();
for (let i = 0; i < 3; i++) {
  try { return await post('/v1/payments', body, { 'Idempotency-Key': key }); }
  catch (e) { if (!retryable(e)) throw e; }
}

If you have your own order id, use it — order_1837 is a better key than a fresh UUID, because it is the same across process restarts.

Rate limits and idempotency

The rate limiter runs before the idempotency layer, on purpose. A 429 written after a key was claimed would become that key's permanent stored answer, poisoning the one mechanism you have for finding out whether your timed-out payment went through. So a 429 is never stored, and retrying with the same key still works.