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.
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.
409 idempotency_key_reused. The key names an operation; a different body is a different operation wearing its name.409 idempotency_request_in_flight. Retry in a moment.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.
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.