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.