Idempotency in Payment Systems: Keys, Retries, and Failure Handling
How to implement idempotency in payment systems — key design patterns, database strategies, and the edge cases that break naive implementations in production.
A payment request may be retried after a timeout even when the first attempt succeeded. Webhooks may also be delivered more than once. Idempotency handling lets the system recognize repeated requests for the same operation and avoid applying the payment twice.
What Idempotency Means in Payment Contexts
An operation is idempotent if executing it multiple times produces the same result as executing it once. In payment systems, this means: if a transfer request is received twice — with the same intent — the money moves exactly once, and both requests return the same response.
Design for retries wherever a request or acknowledgment can be lost after a payment operation completes.
The Idempotency Key Pattern
The standard implementation of idempotency in payment systems uses a client-generated idempotency key — a string, typically a UUID, that the client generates once per business operation and reuses on every retry.
The server:
- Receives the request with the idempotency key
- Checks whether a record exists for this key
- If no record exists: processes the request, stores the result linked to the key, returns the result
- If a record exists: returns the stored result without reprocessing
The unique constraint on idempotency keys must be enforced at the database level. An application-level check (read-then-write) has a race condition: two concurrent requests with the same key both see no record, both proceed to process, and you get a duplicate.
The correct implementation uses a database INSERT with a unique constraint on the key. The first insert succeeds. The second insert fails with a unique constraint violation, which the application catches and handles by returning the existing result.
Retry and Key Lifecycle Cases
The response was lost, not the operation
A transfer was initiated successfully, but the network failed before the client received the response. The client retries. Your unique constraint catches the duplicate. You return the stored result. The client receives the result of the original transfer.
The naive failure mode: the application checks for a record, finds none (because the first request is still in flight, before the record is committed), processes both requests, and commits two records. Prevent this with a status field on the idempotency record — in_progress, completed, failed — that is set to in_progress before processing begins, in the same transaction as the insert.
The key was reused for a different operation
Idempotency keys must be scoped to a specific operation type. A key used to initiate a transfer should never be valid for a refund on the same endpoint. When a key is received with a request body that does not match the original stored request body, return an error — do not process the new operation, and do not return the old result.
Store a hash of the request body alongside the idempotency key. On each request, compare the hash. A mismatch means the client is misusing the key.
The operation failed — what happens on retry?
A failure response does not always establish that the payment failed. Follow the processor’s retry rules and reconcile an unknown outcome before creating another operation. For example, Stripe API v1 replays the first saved response for a key, including a 500 error; validation failures before execution are handled differently. Stripe idempotency behavior.
Keep confirmed failure, confirmed completion, and unknown outcome distinct. Retry the same operation with its original key when the provider permits it; do not generate a fresh key merely because the first response was an error.
Key expiration
Choose retention based on your retry window and the provider’s rules. Stripe API v1 may prune keys after at least 24 hours, after which reusing a key can create a new request. That provider window is not a reason to reuse business-operation identifiers in your own system.
Implement expiration as a background cleanup job, not as part of the request path. Never expire a key for an operation that is still in progress.
Idempotency Across External Calls
Your payment processor also requires idempotency keys. Stripe, Dwolla, and most modern payment APIs accept an idempotency key header. Use your internal idempotency key (or a deterministic derivative of it) as the key you send to your processor. This means that if your system retries the external call, the processor also handles it idempotently.
If you use a different key on each retry to your processor, you may initiate multiple transfers even though your internal system deduplicates them. The idempotency chain must be consistent end-to-end.
Testing Idempotency
Write explicit integration tests for:
- Same key, same body, two concurrent requests (expect: one result, one database record)
- Same key, same body, sequential retry after a timeout (expect: same result as first request)
- Same key, different body (expect: error response, no processing)
- Key for a failed or unknown operation, retried within the window (expect: the documented provider behavior, with no duplicate payment)
- Key for a completed operation, re-request (expect: original result, no reprocessing)
Include concurrent requests and deliberate failure injection in these tests.