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:

  1. Create payment intent.
  2. User confirms (3DS, etc.).
  3. Webhook says payment_intent.succeeded.
  4. 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

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 .