Thodoris Kouleris
Software Engineer
Preventing Duplicate Payment Charges
Imagine this scenario. A customer is checking out and taps "Pay $499". Nothing happens. The loading spinner keeps spinning, so they tap the button again. Still nothing. They tap it a third time. Behind the scenes, all three requests eventually reach your backend.
POST /payments
{
"orderId": "ord_8821",
"amount": 499
}
Because your API treats every request as a brand-new payment, all three are
processed independently.
Request #1 succeeds.
Request #2 succeeds.
Request #3 succeeds.
Stripe receives three charge requests.
Your database stores three payment records.
Instead of paying $499, your customer is charged $1,497.
Before your monitoring system even alerts the on-call engineer, you've
already received an angry support ticket.
This is one of the most common problems in distributed systems: retries
creating duplicate side effects.
Retries aren't bugs, they're expected. A mobile client may retry because, the network timed out, the response was lost, the user tapped the button multiple times or an API gateway retried automatically From the client's perspective, it never received confirmation that the payment succeeded, so retrying is perfectly reasonable. The problem is that your backend can't distinguish a legitimate new payment from a retry of the same payment.
What to do
So, what we want to achieve here? We want a way so the payment request to be only one and not allow retries. In other words, to allow ONE request for each payment.
The correct approach is to make the payment endpoint idempotent. An idempotent operation produces the same result no matter how many times the same request is repeated. Instead of identifying a payment by the request itself, the client generates a unique Idempotency Key.
POST /payments
Idempotency-Key: 9e7f6d4a-52...
{
"orderId": "ord_8821",
"amount": 499
}
Even if the client retries three or ten times, every retry uses the same key.
How the server handles it
When an incoming request arrives, the system looks up its idempotency key: if the key already exists, it skips payment processing entirely and immediately returns the original saved response to prevent duplicate charges. If the key is new, the system reserves it, executes the payment, stores both the result and the generated response for future reference, and returns that response to the client.
Stripe natively supports idempotency through the Idempotency-Key header, allowing your service to safely retry payment requests knowing Stripe will recognize duplicate keys and return the original result rather than executing another charge. By combining this built-in safeguard with idempotency handling at your own API layer, you establish two distinct levels of protection that work together to significantly reduce the risk of accidental double charges.
Storing the Idempotency Key
A common implementation is to maintain an idempotency_keys table.
| Idempotency Key | Status | Response |
|---|---|---|
| abc123 | Completed | Payment ID 501 |
When another request arrives with abc123, the API simply returns the stored response instead of executing the payment flow again. To avoid unbounded growth, these records are typically given a time-to-live (TTL) and removed after a reasonable retention period, such as 24 hours.
---
Network failures are inevitable. Mobile clients will retry. Users will tap buttons multiple times. Load balancers and gateways may also resend requests. The mistake isn't allowing retries, it's treating every retry as a brand-new operation. By implementing idempotency keys, your payment API guarantees that repeated requests for the same payment produce the same outcome: one charge, one payment record, and one happy customer. In payment systems, retries are unavoidable. Duplicate charges are not.