Designing an Idempotent Payment Microservice — Idempotency Key + Atomic Insert + Outbox
Fast recall
Quick revision
Core answer
INSERT the key with status IN_PROGRESS using a DB unique constraint; on conflict, return the cached completed response or a 409 in-progress. Process the charge inside the same transaction as updating the idempotency record to COMPLETED and writing the outbox event. Cache request/response hash + HTTP status for 24 hours with TTL cleanup. Handle mismatched payloads with 422. Retry-safe because any retry either hits the cached response or waits for the in-progress record.Practical distributed-systems correctness — idempotency is a staple interview topic and payment is the canonical hard case
Checking-then-inserting (TOCTOU race creates duplicate charges); storing only success responses (retries after failures charge twice); putting network call outside the transaction (outbox pattern prevents this); no payload hash so different requests with same key succeed silently
Walk the race conditions concretely — two simultaneous retries, mid-process crash, client regenerates key on retry (broken client) — and show the DB schema, locking strategy, and outbox integration
Interview-ready guide
Interview answer
Why this matters for payments specifically
- Double charging is a trust event worse than failure — customer calls support while their balance is double-debited
- Stripe, Adyen, Square all make idempotency a required header, not optional
- The mechanism: client generates a UUID per logical charge attempt, reuses on retry
- Server deduplicates by storing key mapped to response
The race-safe primitive
- Not 'check if key exists, then insert if not' — classic TOCTOU race
- Atomic INSERT with UNIQUE constraint on the key
- DB guarantees exactly one succeeds; the loser catches DataIntegrityViolation and re-reads
- Without this, two simultaneous retries both pass the check and both process
The DB schema
- idempotency_records: key UUID primary key, request_hash SHA-256, status IN_PROGRESS / COMPLETED / FAILED, response_status SMALLINT, response_body JSONB, created_at, completed_at
- charges table: own table; foreign key to idempotency_records
- outbox_events: id, aggregate, event_type, payload, published, created_at
The endpoint flow
- Read Idempotency-Key header; hash the canonical request body
- Look up key; four cases:
- Found COMPLETED with matching hash: return cached response
- Found any status with mismatched hash: return 422
- Found IN_PROGRESS: return 409
- Not found: INSERT IN_PROGRESS; on unique-violation re-enter lookup
- Call payment gateway with our key (defense in depth)
- Single DB transaction: insert charges row, update idempotency record to COMPLETED, write outbox event
- Return 200 with response body
Outbox pattern for downstream events
- Fraud, accounting, notifications subscribe to ChargeCompleted
- Outbox ensures charge commit and event publish are atomic
- Separate publisher drains outbox, marks published, sends to Kafka
- Consumers dedupe on their own using the charge ID
Key race conditions to walk through
Two simultaneous retries with same key
- First INSERT wins; second gets unique-violation and retries lookup
- Finds IN_PROGRESS; returns 409
- First completes; third retry from client sees COMPLETED; returns cached response
- No double charge
Server crash after gateway call, before DB commit
- DB has IN_PROGRESS but no charges row
- Customer charged at gateway; our records don't reflect it
- Retry with same key sees IN_PROGRESS and returns 409
- Solution: pass our key to gateway; next retry's gateway call with same key returns original response
Payload mismatch on key reuse
- First request K1 with amount 1000
- Second request K1 with amount 10000
- Payload hashes don't match; return 422
- Client learns their key logic is broken
TTL expires during long retry window
- TTL 24 hours; client retries at T plus 25 hours
- Key gone; server processes again; double charge
- TTL must exceed maximum client retry window
Gateway-side idempotency
- Upstream gateways accept an idempotency key too
- Pass your key or a derived one to them
- Defense in depth: if our service crashes mid-flow, gateway's idempotency prevents double charge at their layer
FAILED status policy
- Stripe default: return cached failure; client decides next step
- Alternative: retry FAILED; risk of masking real problems
- Policy-driven is best: retry on gateway timeouts, return cached on validation errors
Metrics to expose
- idempotency_hits — seen-before count; spike signals retry storm or clock sync issue
- idempotency_payload_mismatch — client-bug signal
- idempotency_in_progress_returns — concurrent retries; possibly misbehaving client
Testing discipline
- Testcontainers with real Postgres — H2 hides unique-constraint concurrency
- Test concurrent retries converge to exactly one charge
- Test payload mismatch returns 422
- Test gateway failure doesn't corrupt state
Client SDK pattern
- Ship an SDK that generates one key per logical operation and reuses on retry
- Reduces client bugs 10x vs documenting a protocol
Common pitfalls to name
- Check-then-insert race (not atomic)
- No payload hash (different request same key succeeds silently)
- Gateway call outside transaction with no gateway-side key (can double charge)
- TTL shorter than retry window
- Client regenerates UUID per HTTP retry (not logical operation)
- In-memory test DB that hides the race
The strategic answer
- Idempotency key required; atomic INSERT with UNIQUE constraint; payload hash for mismatch detection
- Single transaction for charge, idempotency, outbox
- Gateway-side idempotency for defense in depth
- 24-hour TTL; metrics on hits and mismatches; SDK for clients
- Testcontainers Postgres tests for race safety — H2 lies about concurrency
Designing an Idempotent Payment Microservice — Idempotency Key + Atomic Insert + Outbox
Direct Answer
Require an Idempotency-Key header (client-generated UUID per logical charge attempt). Atomically INSERT the key with status IN_PROGRESS using a DB unique constraint; on conflict, return the cached completed response or a 409 in-progress. Process the charge inside the same transaction as updating the idempotency record to COMPLETED and writing the outbox event. Cache request/response hash + HTTP status for 24 hours with TTL cleanup. Handle mismatched payloads with 422. Retry-safe because any retry either hits the cached response or waits for the in-progress record.
Common Mistakes & Pitfalls
Checking-then-inserting (TOCTOU race creates duplicate charges); storing only success responses (retries after failures charge twice); putting network call outside the transaction (outbox pattern prevents this); no payload hash so different requests with same key succeed silently
Check your understanding
Answer these without looking back at the article.
- Why does check-then-insert have a race condition, and how does UNIQUE constraint fix it?
- How does the outbox pattern solve the 'charge-commit vs Kafka-publish' dual-write problem?
- What's the right TTL for idempotency keys, and what happens if it's too short?
- How would you test race conditions on the idempotency layer in CI?
Related interview prep categories
2Want 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.