Developers › Building on the API
Idempotency
Verified 6 November 2026
Every POST made by the SDK carries an Idempotency-Key, and the same key is kept across the client’s own retries. If a request times out and is retried, we replay the original response instead of doing the work again.
Supply your own when the retry might come from somewhere else — a job runner, a queue, or a customer pressing the button twice:
await sp.paymentIntents.create(params, {
idempotencyKey: `order-${orderId}`,
});The rules
Send the key as an Idempotency-Key header, up to 255 characters (a longer one is a 400 naming the header), on any POST. It is ignored on other methods. A key is remembered for 24 hours. Replaying it returns the original status and body, with Idempotent-Replayed: true on the response.
| A second request with the same key… | Gets |
|---|---|
| …and the same bytes, to the same path, after the first finished | The first response, replayed. |
| …while the first is still running | 409 idempotency_in_progress — or 500 outcome_unknown, if the first has reached the bank and is still running after a minute. Nothing is done twice. Retry the same request, same key, later. |
| …with a different body, a different path, or from a different API key | 400 idempotency_key_in_use. Use a new key for a new request. |
| …after the first was refused before it started | It runs, as though the first had never been sent. See below. |
“The same body” means the same bytes, not the same meaning: the body is hashed as it arrives, so the same fields in a different order, or with different spacing, are a different request. Build the body once and resend that string. Keys belong to your account and mode, not to an endpoint — a key used to create a payment cannot then be used to create a refund.
Reusing a key with a different body is an error, not a second payment. That combination almost always means a bug, and quietly doing the new thing under an old key is how a duplicate charge ends up being blamed on us.
A request that was refused leaves its key free
A key is only used once a request starts executing. A request refused before that stored nothing, so fix it and send it again with the same key: a body that is malformed, empty or fails validation (a 400 with invalid_request_error), a key without the permission (403), or a rate limit (429). There is no need for a new key, and on a request that moves money it is better not to make one a habit.
A refusal that needed to look at your data is a different thing, because that request did start. A payment that has already succeeded, an archived price, a refund larger than what is left, or refund_not_yet_possible: the answer is stored, and the same key replays it. Use a new key once the situation has changed.
If the key has already been used by an identical request, you always get that request’s answer, even if the same body would be refused today.
429, a 5xx, idempotency_in_progress and a dropped connection on its own, with the same key — but only within 30 seconds of the first attempt, so it never outlasts the minute below. After that it hands you the error, which for idempotency_in_progress usually means a request timed out on your side while it was still running on ours: catch it and try again a few seconds later, with the same key. A request that ran for more than a minute without finishing is treated as abandoned and the next attempt with its key runs it afresh — unless it had already reached the bank, in which case the key is never run again (below).5xx, and an error that itself says to retry later — 409 payment_in_progress — which describes the moment rather than your request. Neither is ever freed once a charge or refund has reached the bank: if the bank’s answer is lost, the response is a processing payment or a pending refund, and if we could not even read that back it is 500 outcome_unknown. Replaying the key returns exactly that answer and never reaches the bank again. Do not repeat that request under a new key.A retry that arrives while the first request is still running gets
409 idempotency_in_progress, or outcome_unknown if it is still running after a minute. Either way, retry later with the same key: once the original finishes you get its stored result.Without a key there is nothing to replay, and a retry of a refund or charge that reached the bank can move money twice. The SDK and the dashboard always send one; send one yourself on every
POST that moves money.One rare case errs the same way: if we lose track of whether we had marked your key as reaching the bank, we do not call the bank and answer
503 gateway_unavailable (nothing moved), but that key may answer outcome_unknown for the rest of its 24 hours. Check the payment, then retry under a new key if nothing happened.