Case Study
Idempotent Payment Orchestrator
Backend Architecture • Transactional Systems
Images
Gallery unavailable.
Overview
Designed and implemented a retry-safe payment processing pipeline with strict idempotency guarantees under concurrent transaction submissions. Idempotency keys at request boundary; outbox for provider calls; webhook reconciliation by event_id. Guarantee: no double charge under client retries, duplicate webhooks, or network failure between DB commit and provider call.
Client
↓
API (Django)
↓
PostgreSQL (transactions + idempotency)
↓
Outbox Table
↓
Worker (Celery)
↓
Payment ProviderArchitecture & Design
Transactional Flow Overview
System Invariants
- ·A payment intent cannot transition from failed to succeeded.
- ·Reservation "paid" is set only after a verified webhook; frontend and redirect cannot set it.
- ·Webhook events are processed idempotently by provider event_id.
- ·Availability for a slot is updated under pessimistic lock (SELECT FOR UPDATE); no optimistic commit.
- ·Idempotency keys are scoped per client and stored; duplicate key returns original response.
- ·Payment and reservation state changes for a webhook occur in a single database transaction.
Architecture Decision Records
- ADR-01Idempotency key required for all payment initiation requests
- ADR-02Outbox for provider calls; no side effects inside request lifecycle
- ADR-03Webhook processing idempotent by provider event_id
- ADR-04Transaction boundaries: single DB transaction per state transition
- ADR-05Reconciliation and failure mode handling
Scale & Constraints
- Request volume
- Client and provider retries; requests and webhooks can arrive duplicated or out of order.
- Concurrency
- Single writer per idempotency key; outbox for provider calls. No double charge under retries.
- External dependencies
- Payment provider API; webhooks. Network failures between DB commit and provider call possible.
- Failure modes
- Provider timeout or unreachable after commit → outbox retry. Duplicate webhook → idempotent by event_id. Client retry → same key returns stored outcome.
- Data consistency
- Payment state and outbox in same DB; commit before provider call or outbox. No double charge; idempotency key is sole source of outcome for request.
What was explicitly rejected
- ✕
Simple request-based processing without idempotency keys
Retries and duplicate submissions would cause double charge; key at business layer is required.
- ✕
Handling retries only at HTTP layer
Application state can still double-apply; idempotency must be enforced at orchestration layer with a stable key.
- ✕
Relying entirely on provider guarantees
Provider semantics vary and may not guarantee exactly-once; we own the no-double-charge guarantee.
- ✕
Processing side effects inside request lifecycle
If process dies after DB commit but before provider call, state is inconsistent; outbox decouples and allows retry without re-executing request.