Payment API retries
Not climbed yetModel answer
A method is idempotent when sending the same request once or ten times leaves the server in the same state. The response can differ (a second
DELETEmay return 404), but the effect does not stack.GET,PUTandDELETEare idempotent: reading changes nothing,PUTsets a resource to a value, and deleting twice is still one deletion.POSTis not: twoPOST /ordersnormally create two orders.PATCHis not guaranteed either, because "add 1 to quantity" stacks.It matters because networks fail mid-request, and clients, proxies and load balancers retry. Retrying an idempotent call is safe; retrying a
POSTneeds extra work.Model answer
The client cannot tell whether the first request failed or only the response got lost, so the server must make the retry harmless. Use an idempotency key: the client generates a unique key (a UUID) per payment attempt and sends it in a header such as
Idempotency-Key.The server stores the key with the result of the first request. When the same key arrives again, it skips the charge and returns the stored response. A different key means a genuinely new payment.
The key is scoped to the customer or API account, so one user cannot replay another's key, and the stored request body is compared so a reused key with different data is rejected with an error instead of silently returning the old result.
Model answer
Check-then-insert is a race. The fix is to make claiming the key a single atomic step that only one request can win.
Insert the key first, with a unique constraint, in a "processing" state, before doing any work. The database lets exactly one insert succeed; the loser gets a constraint violation and knows another request owns the key.
INSERT INTO idempotency_keys (account_id, key, status, request_hash) VALUES ($1, $2, 'processing', $3) ON CONFLICT (account_id, key) DO NOTHING RETURNING id; -- no row back means someone else already holds itThe winner does the charge and updates the row with the response. A Redis
SET key value NXworks the same way if the store is Redis, but the database is safer when the payment itself is written there, because the claim and the result can share a transaction.Model answer
While the first request is still running, return 409 Conflict (or 425/429 with
Retry-After) and let the client retry later. Do not start a second charge and do not block the connection for a long time.A crash halfway is the hard case: the row says "processing" forever. Store a timestamp and treat processing rows older than a timeout as abandoned. Before redoing the work, ask the payment provider whether the charge went through, using your own key as the provider's idempotency key, so the provider deduplicates too. That is why the key should be passed all the way down the chain.
Keep keys for at least the client's retry window, commonly 24 hours (Stripe uses 24h), then delete them with a TTL or a cleanup job. Keeping them forever only grows the table; deleting them too early reopens the double charge.
