I am Sajan Acharya, a Senior Software Engineer based in Kathmandu. Payment bugs are expensive in a way that UI bugs rarely are. A timeout after a successful charge, a mobile app retry, or a webhook delivered twice can create double captures, angry customers, and nights spent in gateway dashboards. Idempotency keys are the standard fix: the client (or your server) attaches a unique key to a charge request, and your system guarantees that repeating the same key returns the original result instead of creating a second payment. This how-to is the implementation sequence I use on Node.js backends integrating Stripe, eSewa-style gateways, and custom PSP APIs.
Idempotency is not optional polish for checkout. It is part of the API contract. Pair it with how to implement payment reconciliation so ledger gaps still get caught when webhooks are late or keys expire. If you need help wiring this into production services, my Node.js developer services cover payment-safe API design, queues, and operational runbooks.
What an idempotency key actually guarantees
An idempotency key is a client-generated or server-issued opaque string—often a UUID—scoped to an operation such as “create payment intent for order 1842.” The first request with that key performs the side effect and stores the outcome. Later requests with the same key and the same payload return the stored outcome without charging again. If the payload differs for the same key, you should reject the request: that usually means a bug or a key reuse mistake, not a safe retry.
Scope matters. A key should not be global forever across unrelated endpoints. Bind it to tenant, user or order, and operation type. TTL matters too: keep records long enough to cover mobile offline retries and support investigations—commonly 24–72 hours for checkout, longer if your support SLA needs history. Never treat “HTTP 200 once” as enough proof; networks lie between your process and the gateway.
Generate keys on the right side of the wire
Prefer the client or BFF to generate the key before the first attempt and reuse it on every retry of that same user action. If the browser refreshes and starts a brand-new checkout attempt, that is a new business action—new key. If Axios retries because of a network blip on the same click, same key. For server-to-server jobs that create charges, generate the key when you enqueue the job and persist it on the job record so workers can retry safely.
- Send the key in a header such as Idempotency-Key on POST /payments
- Reject missing keys on money-moving endpoints in production
- Hash or store a fingerprint of the request body with the key
- Return the original status code and body on replay—including pending states
- Never mint a new gateway charge when a completed record already exists for the key
Server storage pattern that survives concurrent retries
Use a durable store with a uniqueness constraint on (tenant_id, idempotency_key) or equivalent. On request start, try to insert a row in state processing. If the insert wins, you own the work: call the gateway, then update the row to succeeded or failed with the response snapshot. If the insert conflicts, read the existing row: if still processing, return 409 Conflict or a documented “in progress” response so the client retries later with the same key; if finished, return the stored response. This compare-and-set approach beats check-then-act races where two workers both think they are first.
Redis locks alone are not enough unless you also persist the final result. Locks expire; payment results must not. PostgreSQL or MongoDB with a unique index plus a status machine is the boring pattern that works. Keep gateway payment IDs, amount, currency, and normalized error codes on the record so support can explain what happened without guessing. Structure this behind a repository port if you use module boundaries—see how to structure a Node.js backend with clean architecture.
Gateway calls, webhooks, and partial failures
Many PSPs already accept an idempotency key on their charge API. Pass your key through when the provider supports it—you want protection on both sides. Still keep your own record: your app may timeout before reading their success response. On timeout, do not invent a second key and “try again harder.” Re-query the provider by your key or by a stored provider reference, or wait and let the webhook confirm. Marking the local row failed too early while the charge succeeded is how silent double-attempts start.
Webhooks must be idempotent too. Store processed event IDs from the gateway and ignore duplicates. A charge.succeeded event should transition your payment to paid only once, even if delivered five times. Treat webhook handlers as reconciliation inputs, not as unconstrained writers. For the nightly or near-real-time matching process that catches the rest, read how to implement payment reconciliation.
Testing and operational habits
Automate tests that fire two concurrent requests with the same key and assert a single gateway call (mock) and identical responses. Add a test where the body changes under the same key and expect 422. Chaos-test timeouts: kill the response after the mock gateway “succeeds” and confirm a retry returns the stored success. In production, alert on conflict rates and on keys that stay in processing longer than your gateway SLA—stuck rows often mean a crashed worker mid-charge.
Document the contract for frontend and mobile teams: which endpoints require keys, how long to retry, and when to mint a new key. Idempotency without client discipline fails. API design guidance in how to design scalable Node.js APIs applies directly—money endpoints are the first place those rules earn their keep.
Ship the safety net, then prove it
From Kathmandu I implement payment APIs for product teams who cannot afford “we think it charged once.” If your checkout still relies on hope after timeouts, get in touch with your gateway, current retry behavior, and where keys live today. We can add durable idempotency, webhook dedupe, and the monitoring that keeps double charges out of your support queue—then connect the same ledger to reconciliation so nothing slips between systems.