Money requires more rigor than other state. Get an order wrong and you have an angry customer; get a payment wrong and you have a lawyer. This post is the working architecture for payment systems.
Use a gateway
Don’t build the card-acceptance layer. Use Stripe, Adyen, Razorpay, Mercado Pago, etc. They handle PCI, fraud, network connections to card schemes. You build the orchestration.
For developer markets: Stripe for ease; Adyen for enterprise; regional players for region-specific.
The architecture
Client → Your API → Payment intent (Stripe) → Card networks → Bank
│
▼
Your ledger (double-entry)
│
▼
Webhook listener (state changes)
│
▼
Fulfillment (ship, grant access, notify)
Each arrow is a place to be careful.
Idempotency keys (mandatory)
Every payment-creating call uses an idempotency key:
POST /payments
Idempotency-Key: ord-2026-04-1234567
Network blips happen. Without idempotency, retries become double-charges. See Idempotency, Retries, and Exactly-Once Illusions .
Double-entry ledger
Every business event is two ledger entries:
Customer buys $100 of widgets:
DEBIT customer_account_42 $100
CREDIT revenue $100
Stripe pays you (minus fees):
DEBIT bank_account $97.10
CREDIT customer_account_42 $97.10
DEBIT fees_expense $2.90
CREDIT customer_account_42 $2.90
Sum of debits = sum of credits. Always. Built into the schema:
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL,
account_id BIGINT NOT NULL,
amount_cents BIGINT NOT NULL, -- positive or negative
direction TEXT NOT NULL, -- 'debit' | 'credit'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Constraint: sum of entries per transaction = 0
A ledger constraint check or assertion in code enforces invariant: every transaction balances.
Async fulfillment
Don’t fulfill synchronously on payment intent creation. Pattern:
- Create payment intent.
- User confirms (3DS, etc.).
- Webhook says
payment_intent.succeeded. - Then fulfill.
The payment intent might fail at step 2; fulfillment based on step 1 ships product without payment. Always wait for webhook.
For workflow orchestration see Temporal Durable Execution — payments are a textbook case.
Webhook handling
Stripe sends webhooks for state changes. Verify signatures; deduplicate by event ID:
@app.post("/webhook/stripe")
async def stripe_webhook(request: Request):
body = await request.body()
sig = request.headers["stripe-signature"]
event = stripe.Webhook.construct_event(body, sig, WEBHOOK_SECRET)
# Dedup
if await db.fetchval("SELECT 1 FROM processed_events WHERE id = $1", event["id"]):
return {"ok": True}
async with db.transaction():
await handle_event(event)
await db.execute("INSERT INTO processed_events (id) VALUES ($1)", event["id"])
return {"ok": True}
See Webhook Design 2026 .
Reconciliation
Daily job: pull Stripe’s report, compare against your ledger. Discrepancies become tickets.
Without reconciliation, drift accumulates. With it, you find the bug at $5 instead of $5,000.
PCI scope
Use Stripe.js (or equivalent). Card details go directly from the user’s browser to Stripe. Your server never sees them. PCI scope: minimal.
If your server touches card numbers, you’re in PCI Level 1 territory. Years of compliance work. Just don’t.
Refunds, chargebacks, disputes
Each is a state change with money implications:
- Refund: customer-initiated. Reverse ledger entries.
- Chargeback: bank-initiated. You may or may not win the dispute.
- Dispute: you respond with evidence. Stripe handles paperwork.
All three need ledger entries reflecting the change.
Fraud rails
- Stripe Radar (or Adyen RevenueProtect) handles most.
- Custom rules for your domain (max order size for new accounts, IP geolocation mismatches, etc.).
- Manual review queue for borderline cases.
Multi-currency
If you sell across currencies:
- Store ledger entries in the originating currency.
- Compute USD-equivalent at transaction time (for reporting).
- Don’t mix currencies in the same account.
Currency conversion adds complexity; payments services handle it but you must reason about it.
Common mistakes
1. Not using idempotency keys
Network blip → double charge → angry customer.
2. Fulfilling on intent creation
Premature; payment can still fail.
3. No double-entry
Single-line “debited the account” rows that don’t balance lead to drift you can’t reconcile.
4. Touching card numbers
PCI scope explosion. Use the gateway’s tokenization.
5. No reconciliation
Drift compounds. The first time you reconcile, you’ll find errors.
Read this next
- Idempotency, Retries, and Exactly-Once Illusions
- Temporal Durable Execution
- Webhook Design 2026
- PostgreSQL MVCC, Isolation, Locking
If you want a payment-orchestrator + ledger reference (Postgres + FastAPI + Stripe), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .