Two requests.
One charge.
A payment request can be sent twice: timeouts, double clicks, client retries. Retry one: the naive API charges the card again, while the idempotency key replays the first response.
Naive, charges every request
- customer charged
- $0
- overcharged
- $0
Idempotency key, one charge per key
- customer charged
- $0
- replayed
- $0
Send a payment, then retry it: networks time out, users double-click, clients resend. That was the same request: the naive API charged the card again; the idempotent one replayed the first response.
How idempotency keys actually work read more
When a request times out, the client cannot know if the charge happened: maybe only the response was lost. Retrying is the only safe recovery, but the naive API treats the retry as a new payment and the customer is charged twice.
The fix: the client generates a key per logical payment and sends it with every attempt. The API inserts the key, with the response it is about to return, in the same transaction as the charge:
BEGIN;
INSERT INTO idempotency_keys (key, response) VALUES ($1, $2);
-- unique(key): a retry aborts here and replays the stored response
INSERT INTO payments (..., status='captured');
COMMIT; A retry hits the constraint, aborts, and returns the stored response: the client cannot tell a replay from the original. The constraint matters: checking for the key first is a race where two concurrent retries both find nothing and both charge.
The cost: the client must generate and persist the key across its own crashes, responses must be stored, and keys need a scope and an expiry: judgment calls, not defaults.