Design Desk

Payments

Design a Payment Gateway with Idempotent Retries

A gateway sitting between a merchant and a card processor, and the one mechanism that stops a client's retry from becoming a second charge.

A payment gateway sits between a merchant's server and an external processor (a card network, a bank rail, whatever). The merchant calls POST /charges once. The network between the merchant and the gateway does not guarantee that call arrives once, or that the response makes it back. Your job is to make a network that behaves at-least-once look exactly-once from the merchant's side, without ever charging the same card twice for the same intent.

01 Requirements

Functional

  • Accept a charge request: amount, currency, a payment method token, and a client-supplied idempotency key.
  • Forward the charge to the processor and return a definitive outcome: succeeded, failed, or pending.
  • A retried request with the same idempotency key returns the original outcome. It never re-executes the charge.
  • Expose a status lookup by idempotency key or charge ID, for a merchant that lost track of which of its own requests actually landed.

Non-functional

  • Effectively exactly-once from the merchant's point of view, built on transport that only promises at-least-once.
  • The charge decision, once made, survives a crash of the gateway process between "processor responded" and "response sent to merchant." That window is where naive designs lose money or double-charge.
  • Synchronous path latency dominated by the processor round trip, not by the gateway's own bookkeeping.
  • Every state transition on a charge is written down. Disputes and reconciliation run off that log, not off memory.

02 Estimation

No estimator component exists in this repo yet, so plain napkin math.

Say peak load is 500 charge attempts/second. A processor round trip averages 800ms. At steady state that's roughly 500 × 0.8 ≈ 400 requests in flight at once, which is what sizes your connection pool to the processor, not the request rate itself.

Idempotency keys need to live long enough to catch a client that retries after a slow response or a reconnect, not just an instant retry. Say a 24-hour window. At 500/s sustained that's about 43 million keys a day. Each record is small (key, status, a pointer to the stored response, a couple of timestamps), call it 200 bytes: roughly 8.6GB/day of dedup records.

That number is the whole argument for the two-store design below: 8.6GB/day of short-lived lookups is exactly what an in-memory store with TTL is for. It is not what you want your durable ledger doing millions of times a day just to answer "have I seen this key before."

03 High-Level Design

Rendering diagram…

Redis answers 'have I seen this key' fast. Postgres is the durable record a charge decision is written to before the gateway ever replies. The processor can also call back asynchronously if it settles after the synchronous window closes.

The gateway API is the only thing that talks to Redis, Postgres, and the processor adapter. A merchant never sees any of those three directly, only the charge outcome and, later, a webhook if you expose one.

04 Deep Dive

The interesting behavior isn't the first request. It's the second one, when the merchant's own client times out waiting and calls POST /charges again with the same key.

MerchantGatewayRedisProcessor
The first attempt for a key runs the full path out to the processor. A retry with the same key never leaves the gateway. Redis already has the answer, and the gateway hands it back.

That asymmetry is the whole design. A retry isn't a second attempt at the same work, it's a lookup. If your implementation makes the retry path touch the processor again "just to be safe," you have not built idempotent retries, you've built a very well-documented way to double-charge a card during a network blip.

The other case worth naming: the merchant's request to the gateway times out, but the gateway's request to the processor did not. From the merchant's side, the outcome is unknown. From the gateway's side, once the processor responds, the outcome is known and gets written to Postgres before the response goes anywhere. A retry with the same key, arriving any time after that write, finds the resolved outcome and returns it. A retry arriving while the first attempt is still in flight has to wait on the in-progress record rather than starting a second processor call, which is the part a lock or a unique constraint has to actually enforce.

Confirming that this behavior actually holds in production, that a retry lands on Redis and never reopens a call to the processor, is exactly the kind of single-request question distributed tracing answers directly, rather than something pieced together from separate logs on the gateway and the processor adapter.

Try it: send a payment, note the processor call count and the charge ID, then send again with the same key, the count doesn't move and the charge ID is identical. Check "Simulate lost response," send with a fresh key, then send again with that same key, the client never saw the first answer, but the retry still returns the original charge instead of a new one. Click New Key and send again to see a genuinely new payment go through.

Processor calls

0

Real charges submitted to the card processor

Idempotency store (Redis)

Empty

No requests sent yet.

05 Compare

The single trade-off that decides whether this design is correct under concurrency: where does the idempotency check actually get enforced.

 Postgres unique constraintRedis SETNX + TTL
DurabilitySurvives a restart. It's the system of record.Gone if Redis restarts without persistence, or the TTL fires early.
Concurrency safetyThe database rejects a second insert on the same key atomically. No race window.SETNX is atomic too, but the key's TTL is a timer, not a correctness guarantee: it can expire mid-flight.
LatencyA DB round trip, possibly under lock contention, on every single request.In-memory. Built for a hot path that runs on every request.
What it's actually forThe final answer for whether this charge happened.A fast pre-check in front of that answer, not a replacement for it.

In practice you want both, not one or the other: Redis for the fast rejection on an obvious repeat, Postgres's unique constraint on idempotency_key as the enforcement that's actually allowed to be the last word.

06 Quick Check

A merchant's request to charge a card times out after 5 seconds with no response. The charge may have already succeeded on the processor's side. What should the merchant's retry do?