Payment Orchestrator
A Stripe-style payment gateway with an asynchronous processing engine, resilient webhooks and idempotent APIs.
- Node.js
- Express
- PostgreSQL 15
- Redis 7
- BullMQ
- React
- Vite
- Docker Compose
- Services
- api · worker · db · redis · dashboard · checkout
- Pattern
- Async job queue · idempotency keys · webhook retry
- Data
- PostgreSQL ledger with refund reconciliation
Overview
Payment Orchestrator replicates the shape of a real payment processor: a merchant onboards, receives API credentials, creates orders through a REST API, sends customers to a hosted checkout, and watches transactions settle on a dashboard.
Six containers make up the system — api, worker, db, redis, dashboard and checkout — orchestrated with Docker Compose. A test merchant is seeded on startup so the full flow can be exercised with a single curl and a browser.
Problem
A payment is not a single request. It is a lifecycle: order created, checkout opened, payment attempted, bank responds (eventually), merchant notified, maybe refunded. Every one of those steps can fail, be retried, or arrive twice.
The dashboard, the checkout page and the processing engine all read and write that lifecycle. If they disagree, a customer sees 'paid' while a merchant sees 'pending' — the exact class of inconsistency this project was built to handle.
Architecture
- 01The API accepts orders and payments and immediately enqueues processing on a Redis-backed BullMQ queue, so request latency is independent of bank latency.
- 02A dedicated worker consumes the queue, runs the payment engine (with fault injection for latency and bank failures), writes results to PostgreSQL and schedules webhook delivery.
- 03Hosted checkout is an isolated micro-frontend that supports UPI and card (with Luhn validation) and polls the API for status. The merchant dashboard is a separate React app with analytics, history and credential management.
Implementation
Order creation with API credentials
Merchant requests authenticate with an API key and secret sent as headers. Amounts are integers in the smallest currency unit.
1curl -X POST http://localhost:8000/api/v1/orders \2 -H "Content-Type: application/json" \3 -H "X-Api-Key: key_test_abc123" \4 -H "X-Api-Secret: secret_test_xyz789" \5 -d '{"amount": 50000, "currency": "INR", "receipt": "demo_1"}'Async payment engine
Instead of processing inside the request, the API enqueues a job. The worker picks it up, simulates the bank round-trip, and persists the outcome — PROCESSED, FAILED or otherwise — before notifying the merchant.
Resilient webhooks
Merchant notifications are delivered by a retry loop with exponential backoff, up to five attempts. Failed deliveries never block the payment itself.
Refunds and the ledger
Partial and full refunds are first-class operations reconciled against a ledger, so the dashboard's numbers are derived from the same records the engine wrote.
Challenges
Duplicate requests
Retries from a flaky client or a double-clicked pay button must not create two charges. Idempotency keys make repeated requests return the original result.
Bank latency and failure
Fault injection simulates slow and failing bank responses; the queue absorbs the latency and the status polling on checkout keeps the customer informed.
Three surfaces, one truth
Dashboard, checkout and worker share PostgreSQL as the single source of truth; the queue carries intent, the database carries state.
Technical decisions
Redis + BullMQ over an in-process queue
A real broker gives durability, retries and a clean worker boundary that can be scaled independently of the API.
Separate checkout micro-frontend
Isolating checkout mirrors how hosted checkout pages work in production and keeps card handling out of the merchant dashboard.
PostgreSQL for the ledger
Financial records want transactions, constraints and reconciliation queries — a relational store is the natural fit.
Result
A reproducible, containerised payment system that demonstrates the hard parts — asynchronous processing, idempotency, retries with backoff and refund reconciliation — end to end, from `docker-compose up` to a settled transaction on the dashboard.
Lessons
- 01The queue is not an optimisation; it is the boundary that makes the rest of the system reasonable.
- 02Idempotency has to be designed at the API contract level, not bolted on later.
- 03Fault injection during development surfaces the failure paths that tests written for the happy path never will.
