Skip to content

Idempotency

Every state-changing gateway request takes an Idempotency-Key header, and on POST /v1/charges it is required.

Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7

A request can time out after we have already done the work. Your connection drops, your load balancer gives up, your process is killed mid-flight — and the charge exists. From your side the two cases are indistinguishable: you got no response.

With no idempotency key, your only options are to retry (and risk charging twice) or not to retry (and risk silently losing the payment). Neither is acceptable when the subject is money.

With a key, a retry is a replay. The second request returns the first request’s response — the same transaction id, the same status, the same everything — and no second charge happens.

One key per logical operation, not per HTTP attempt. Generate it when you decide to take a payment, store it alongside your order, and send the same value on every attempt at that payment.

order 1043 → key 7c9e6679-… attempt 1: timeout
attempt 2: same key → replay
attempt 3: same key → replay

A new payment for the same order later — a customer retrying after a genuine failure — is a new logical operation and gets a new key. If you reuse the key, you will be replayed the old failure instead of making a new attempt.

Use a UUIDv4 or anything else with enough entropy to be unique. Do not use your order id: two payment attempts on one order need two keys.

The original response, including its status code. A replayed 201 is a 201. You do not get a special “this was a replay” status, because your code should not need to branch on it — that is the point.

Claiming a key is a conditional insert, so two concurrent requests with one key produce exactly one winner. The loser does not create a second charge.

The two errors that mean your client has a bug

Section titled “The two errors that mean your client has a bug”

The same key arrived with a different request body.

This is not a transient condition and retrying will not help. It means two different operations are sharing a key — most often because the key was derived from something insufficiently unique, like an order id or a timestamp truncated to the second.

We return an error rather than replaying the first response, which is the important design choice here. Replaying would hand you a 201 describing a charge for an amount you did not ask for, and you would have no way to notice. Being told is worse in the moment and far better overall.

A concurrent request is holding this key right now. The first one has not finished.

Retry shortly — a few hundred milliseconds — and you will get either the real response or a replay of it. Do not generate a new key and resend: that is how you turn one payment into two.

Do not use idempotency as your only safeguard against duplicates in your own system. It protects the boundary between you and us. If your own code can decide to take the same payment twice, it will, and it will correctly generate two keys while doing it.

GET requests change nothing, so they take no key. Neither does creating a webhook endpoint — registering the same URL twice in one environment is a 409 rather than a second endpoint, which is its own form of idempotency. If a create there times out, list your endpoints: if it is present, rotate its secret rather than creating it again.