Every payment integration eventually meets the same scenario. A customer taps pay. The request reaches your server, the charge succeeds at the gateway, and the response is lost on the way back. The customer sees a spinner, then an error, and taps pay again.
What happens next is determined entirely by design decisions made months earlier.
Retries are the normal case
Mobile networks drop connections. Load balancers time out. Gateways return 502 while having already processed the transaction. Client libraries retry automatically, often without telling the application. Any system that assumes each request arrives exactly once will eventually charge somebody twice, and the failure will be discovered by the customer rather than by your monitoring.
The mechanism
The client generates a unique key for the logical operation, not for the HTTP request. One checkout attempt gets one key, and every retry of that attempt carries the same key. The server stores the key with a unique constraint before performing any side effect.
On a repeat, the insert fails on the constraint, the server finds the original record and returns the original response. The customer sees the result of the first attempt. No second charge occurs.
The unique constraint is the load-bearing part. Checking whether a key exists and then inserting it is not safe: two concurrent requests can both pass the check. Let the database enforce it and handle the constraint violation.
Storing the response matters as much as blocking the write
An idempotent endpoint that blocks the duplicate but returns an error is only half correct. The client cannot distinguish that error from a genuine failure and may escalate to a human or, worse, retry through a different path. Store the original response body and status alongside the key, and replay it.
Scope and expiry
Scope keys per customer or per merchant rather than globally, so an unlucky collision between unrelated clients is impossible. Keep keys for at least twenty four hours, which comfortably exceeds any reasonable client retry window, and expire them afterwards so the table does not grow without limit.
Webhooks need the same treatment in reverse
Gateways deliver webhooks at least once, which means sometimes more than once. Every webhook handler needs the same protection, keyed on the provider event id. Store the event before processing it and ignore anything already seen. A refund processed twice because a webhook was delivered twice is the same class of bug arriving from the other direction.
Test it deliberately
Write a test that fires the same request twice concurrently and asserts that exactly one charge exists. This is not a rare edge case to be discovered in production; it is the ordinary behaviour of the internet, and it deserves a test that runs on every commit.