Idempotent Payment API Design
Fast recall
Quick revision
Core answer
Idempotency semantics under retries, concurrent-duplicate handling (lock vs DB constraint), exact-response replay, and how to keep the gateway charge consistent with the local DB.
Using business data (amount, customer_id, order_id) as the idempotency key, or relying on cache-only dedup with no durable backstop.
Discuss the simultaneous-arrival race, payload-fingerprint mismatch handling, request-scoped vs API-key-scoped key namespaces, and the outbox pattern for gateway-DB consistency.
Interview-ready guide
Interview answer
The problem
A user clicks Pay, the request times out, the client retries. If the server processes it again, the customer is charged twice. Idempotency means making the same request multiple times has the same effect as making it once. For payments it's non-negotiable.
Clarifying questions I'd ask first
- Who generates the idempotency key — client only or server fallback?
- Idempotency key namespace — global, per merchant, per API key?
- Behavior on payload mismatch —
409(Stripe-style) or silent replay? - TTL on stored responses — 24 hours typical?
- Long-running flows like 3D Secure — in scope?
Assumptions for this design
Client generates a UUID v4 per request attempt, scoped to (api_key, key) so merchants can't collide. 24-hour TTL on stored responses. Payload mismatch returns 409. 5,000 QPS at peak. Sub-100ms p99 server-side. Single primary region with cross-region async replication.
Quick capacity check
- 5K QPS, sub-100ms latency budget
- Idempotency table grows ~430M rows over 24-hour window at peak — needs daily cleanup
- Redis caches last 24 hours of responses; ~50 GB at average response size
Storage — two layers, both essential
- Redis for the fast path: cached response by
idem:{api_key_hash}:{key}with 24h TTL, plus a lock key with 60s TTL - PostgreSQL
idempotency_recordstable with PRIMARY KEY (api_key_hash, idempotency_key) — the airtight backstop - A duplicate INSERT raises a unique-constraint violation, which we catch and convert into a replay
- Cache alone isn't enough — Redis restart shouldn't permit duplicates
The dedup algorithm — step by step
- 1Hash the API key, fingerprint the canonicalized request body
- 2Fast path: check Redis for cached response
- 3If found and fingerprint matches, replay; if fingerprint differs, return
409 - 4Acquire a Redis SETNX lock with 60-second TTL
- 5Try INSERT into the idempotency table; on UNIQUE violation, fetch the existing row and replay
- 6Process the payment in a DB transaction that also writes to the outbox
- 7UPDATE the idempotency record with the response, write to Redis cache, release lock, return
Why both lock AND DB constraint
The lock makes the common case (concurrent retries) fast — they wait briefly and replay rather than racing through the slow path. The UNIQUE constraint is the airtight backstop if Redis is down or the lock TTL expires mid-processing.
Request fingerprinting
- Canonicalize the JSON: sort keys, normalize whitespace, lowercase known case-insensitive fields, then sha256
- If a duplicate key arrives with a different fingerprint, return
409— surfaces the client bug instead of silently doing the wrong thing
Outbox for gateway-DB consistency
- We never want to call Stripe and then fail to record it, or record and then fail to charge
- Same DB transaction inserts the payment row AND a payment_outbox row
- An outbox worker drains pending rows, calls the gateway with our payment_id as the gateway's idempotency key
- If the worker crashes mid-charge, the next worker retries; the gateway's own idempotency returns the original charge result
Long-running flows — 202 Accepted pattern
- 3D Secure, Apple Pay, ACH can take 30 seconds; holding the HTTP connection open guarantees client timeouts and retry storms
- Return
202 Acceptedwithin 2 seconds withpayment_id,status: requires_action, and the next action payload (3DS redirect URL) - Client polls
GET /payments/{id}or subscribes to a webhook for completion - Same idempotency key still identifies the whole flow — a retry while requires_action returns the same next_action
LLD — clean abstractions
IdempotencyMiddlewarewraps the controller action — dedup is orthogonal to payment logicIdempotencyStoreinterface with a two-layer impl (Redis + Postgres); could swap to DynamoDB for multi-regionRequestFingerprinterfor canonicalization rulesOutboxWriterhappens inside the same DB transaction as the payment row
Failure handling
- Redis down: fast path skipped, every request goes through the DB; latency up but correctness preserved
- DB violation race: catch and replay
- Worker crash: lease expires, watchdog re-marks pending, next worker retries
- Gateway success but our DB write fails after: outbox row stays processing, watchdog re-runs, gateway returns idempotent success, DB updates
- Client retries with different body: fingerprint mismatch,
409 - Old records: daily cleanup of
expires_at < NOW()
Production extras
- API key hashed (sha256) before storage so leaked DB doesn't expose secrets
- Stored response bodies encrypted at rest
- Audit log of every payment attempt for regulatory retention
- Distributed tracing through middleware → service → outbox worker → gateway
At 10× scale
- DynamoDB Global Tables for multi-region active-active dedup with conditional writes
- CDC (Debezium) replacing outbox polling
- Per-merchant TTL config
Recommendation
Client-generated UUID Idempotency-Key, two-layer dedup (Redis fast path + PostgreSQL UNIQUE backstop), request-body fingerprint check returning 409 on mismatch, transactional outbox for gateway consistency forwarding our key as the gateway's idempotency header, and 202 Accepted for any flow longer than 2 seconds — the same pattern Stripe, Adyen, and Square run in production.
Idempotent Payment API Design
Direct Answer
Make every payment request carry a client-generated Idempotency-Key (UUID) header. The server uses two-layer dedup: an in-memory Redis SETNX lock + cached response for fast replay, backed by a database UNIQUE constraint on (api_key, idempotency_key) as the durable guarantee. On a duplicate, return the byte-identical original response (status code, body, headers) without re-executing. Pair with the transactional outbox pattern so the DB write and the eventual gateway charge can't get out of sync. For long-running flows (3D Secure, async wallets), return 202 Accepted with a status URL.
Common Mistakes & Pitfalls
Using business data (amount, customer_id, order_id) as the idempotency key, or relying on cache-only dedup with no durable backstop.
Check your understanding
Answer these without looking back at the article.
- How would you extend this to support refund requests — same idempotency surface or a separate one?
- Walk through what happens if the client uses the same idempotency key from two different IPs within the same second.
- How does Stripe's behavior of caching response bodies for 24 hours interact with status changes that happen during that window?
- Compare an in-process lock vs a Redis distributed lock vs a DB UNIQUE constraint for serializing concurrent duplicates.
Related interview prep categories
1Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.