TL;DR — A resilient payment system is built on three pillars: idempotent, event-driven transaction processing; strict data isolation (tokenization, vaulting) to shrink PCI scope; and a layered defense that treats every external boundary as untrusted. The patterns below are what separate a system that survives Black Friday from one that double-charges customers and lands on the news.

Why Payment Systems Are a Different Beast

Most distributed systems worry about throughput, latency, and correctness. Payment systems add a fourth, ugly dimension: money. When your service is down, users see a 500 page. When Stripe or a card processor has a bad day, users see duplicate charges, missing refunds, and a trust deficit that takes quarters to rebuild.

The numbers make the stakes concrete. Stripe documented a 2023 outage that lasted just over an hour but affected thousands of merchants globally, with the incident report openly published to preserve trust. Adyen’s engineering blog describes how their platform is designed around “graceful degradation” — the idea that a regional failure must never cause a financial inconsistency, only a delay.

The core invariants every payment architecture must protect:

  • No money lost: every successful authorization must either settle or be explicitly voided.
  • No money created: a refund or credit cannot exist without a corresponding captured transaction.
  • Idempotency everywhere: retrying a request must never produce a second charge.
  • Auditability: every state change is traceable to an actor, a timestamp, and a reason.

Get these right and the rest is engineering taste.

The Core Architecture: Event-Sourced Transaction Ledger

The single most important decision is making the transaction ledger the source of truth, not the balance. Balances are derived; ledger entries are facts.

A canonical layout uses append-only event streams, one per account or wallet. Each event represents an immutable state change: authorize, capture, refund, chargeback, fee. The current balance is computed by folding the stream forward. This pattern is how Stripe’s ledger and modern banking cores like Thought Machine’s Vault are structured.

-- Simplified ledger schema (Postgres)
CREATE TABLE ledger_entries (
    id           BIGSERIAL PRIMARY KEY,
    account_id   UUID        NOT NULL,
    entry_type   TEXT        NOT NULL,  -- 'authorize','capture','refund','chargeback'
    amount_minor BIGINT      NOT NULL,  -- store as integer minor units
    currency     CHAR(3)     NOT NULL,
    external_id  TEXT        NOT NULL,  -- idempotency key
    parent_id    BIGINT      REFERENCES ledger_entries(id),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    metadata     JSONB       NOT NULL DEFAULT '{}'::jsonb
);

CREATE UNIQUE INDEX idx_ledger_external_id ON ledger_entries (external_id);

The external_id unique index is the idempotency primitive. If a client retries with the same key, the second insert fails fast and the original entry is returned. No double charges. No partial state.

In production, this ledger is typically written to first, then projected into read-optimized balance views in a stream processor like Apache Kafka consumers or Materialize. This separation is what lets the system survive a downstream outage: writes continue, projections catch up later.

Why Event Sourcing Wins for Payments

Two reasons dominate:

  1. Replayability. When a regulator asks “what did this account look like on March 14?”, you replay the stream. When a bug corrupts a projection, you rebuild it from the canonical log.
  2. Exactly-once semantics for money movement. Combined with idempotency keys, event sourcing makes the “at-least-once delivery” problem tractable. You accept duplicate delivery at the message layer and deduplicate at the ledger layer.

Idempotency: The Only Correctness Guarantee That Matters

Every external call into a payment system must be idempotent. Every single one. Network retries are not an edge case — they are the default behavior of any well-written client, mobile app, or service mesh.

The standard pattern, codified by Stripe’s idempotency guide, uses a client-supplied Idempotency-Key header stored alongside the result:

POST /v1/charges
Idempotency-Key: 7a3b9c2e-4f1a-4d8b-9e3a-2c5f7b8a1d6e
Content-Type: application/json

{ "amount": 4999, "currency": "usd", "source": "tok_visa" }

The server stores (idempotency_key, request_fingerprint, response_body, status_code) with a TTL (typically 24 hours). On retry:

  • If the key exists and the request fingerprint matches → return the cached response.
  • If the key exists and the fingerprint differs → return 409 Conflict. Never silently mutate.
  • If the key does not exist → process and store.

This is the difference between a system that handles 10,000 retried requests per minute cleanly and one that processes them as 10,000 new charges.

A common mistake is treating idempotency as a per-endpoint concern. It isn’t. It’s a system-wide invariant. The tokenization service, the ledger write, the fraud check, and the webhook delivery all need their own dedup primitives, ideally backed by the same idempotency store.

Reducing PCI Scope Through Tokenization and Vaulting

PCI DSS is the compliance regime every payment engineer eventually meets. Its scope is determined by where card data (PAN, CVV, expiry, magnetic stripe) lives. The cheapest way to reduce scope is to never let it touch your systems.

The dominant pattern is hosted fields and tokenization, popularized by Stripe Elements and Adyen Drop-in. The flow:

  1. The browser loads a JS SDK from the payment processor.
  2. The SDK collects card data in an iframe served directly from the processor’s domain.
  3. The form submission to your backend carries a token, never the PAN.
  4. Your backend uses the token to create charges.

Your servers never see the card number, so they fall outside PCI DSS’s strictest requirements. Audit cost drops, incident blast radius shrinks, and the data-breach headline becomes someone else’s problem.

For recurring billing or saved cards, the token maps to a vault record at the processor. Your system stores only the opaque token. The processor handles card storage, encryption, and key rotation, often using HSM-backed key management described in AWS Payment Cryptography or Google Cloud’s External Key Manager for payments.

Even within your reduced scope, the rules bite. PCI DSS v4.0 introduced expanded MFA requirements, targeted risk analyses, and stronger authentication for all access into the cardholder data environment. Treat v4.0 as the baseline, not v3.2.1.

Event-Driven Settlement and Reconciliation

Authorization is synchronous. Settlement is asynchronous. Conflating the two is a classic source of accounting drift.

A modern payment pipeline separates these stages with a message bus:

Client ──▶ API Gateway ──▶ Authorization Service ──▶ Kafka (auth.events)
                                              └─▶ Fraud Service (async)
                                              └─▶ Ledger Service (write)
                                              └─▶ Webhook Delivery

The authorization service emits an auth.succeeded or auth.failed event. Downstream consumers handle capture (typically batched at end of day by the processor), fraud scoring updates, and customer notifications. Each consumer is independently scalable and independently restartable.

This is the architecture described in depth in Uber’s payment platform posts and Grab’s financial services engineering blog. The benefit that doesn’t show up in diagrams is blast radius isolation: when the fraud service goes down, authorizations still complete and the queue grows. When the webhook service misbehaves, no money moves incorrectly.

Reconciliation as a First-Class Service

No matter how good the design, real money flows through real bank rails that are eventually consistent and sometimes lossy. You need a reconciliation service that:

  • Ingests daily settlement files from the processor (CSV, SFTP, or API).
  • Joins them against your internal ledger.
  • Flags discrepancies: missing captures, amount mismatches, currency drift.
  • Opens a ticket or auto-reverses based on policy.

The service should run as a scheduled job — Airflow, Dagster, or Temporal workflows all work — and its output is a financial report your finance team can actually trust. Without it, you’ll discover discrepancies in the monthly close that took 30 days to accumulate and are now expensive to unwind.

Failure Modes the Patterns Exist to Handle

Listing the failure modes explicitly is more useful than listing the patterns in the abstract. Here is the production taxonomy:

FailureWhat goes wrongPattern that mitigates it
Duplicate client retryCustomer charged twiceIdempotency keys at API edge and ledger
Processor 5xx during captureAuth succeeded, capture unknownOutbox + reconciliation
Database failover mid-writePartial state, unclear if chargedEvent-sourced ledger + idempotent writes
Currency conversion raceTwo reads of FX rate, different valuesSnapshot FX rate at auth time, store with entry
Webhook delivery failureCustomer notified too early/lateAt-least-once delivery with dedup on event ID
Clock skew between servicesRefund processed before authLogical timestamps (e.g., Hybrid Logical Clocks) or vector clocks
Card data exfiltration via logsPCI breachTokenization + log scrubbing at ingest
Insider reads a customer’s PANPrivilege escalationField-level encryption + HSM-backed keys + audit

Each row maps to a real postmortem from the public record. The most instructive are Stripe’s published incident reports and Cloudflare’s resilience writeups.

Security Beyond PCI: The Layered Defense

PCI scope reduction is necessary but not sufficient. A modern payment system also needs:

  • mTLS between services. Linkerd and Istio make this operationally tractable. Every internal hop is authenticated and encrypted.
  • Rate limiting at the edge. Envoy with a Redis-backed rate limit service, or managed offerings like Cloudflare. Brute force enumeration of card tokens is a real attack vector.
  • Anomaly detection on the transaction stream. Models flag unusual amounts, geographies, or merchant categories. Features stores like Feast plus online inference on the auth path work well; the cost of a false positive (declined legit transaction) is bounded and worth paying.
  • Webhook signing. Every outbound webhook carries an HMAC signature. Stripe’s webhook signature verification is the canonical implementation. Without it, your callbacks are spoofable.
  • Least-privilege IAM. AWS IAM policies that grant the payment service access to exactly one KMS key, one S3 bucket, and one DynamoDB table. No more, no less. Blast radius minimization is the goal.

A useful exercise: model the threat as an attacker who has already compromised one of your services. What can they do? If the answer is “move money to any account,” your architecture is wrong.

Observability: Knowing When Money Is at Risk

A payment system without good observability is a payment system waiting to lose money in ways you can’t see.

Three signals matter most:

  1. Authorization success rate by processor and BIN. A sudden drop to 80% from 99% means a processor or card type is misbehaving. Page on this.
  2. Reconciliation delta. The number of unmatched transactions in the daily close. Should be zero. Anything else is a potential loss.
  3. P99 authorization latency. Card auths are time-sensitive; a slow auth looks like a failed auth. Track and alert.

Wire these into the same observability stack as the rest of your services — Datadog, Grafana + Prometheus, OpenTelemetry instrumentation everywhere. Tag every metric with merchant_id, currency, and processor so you can slice by the dimensions that actually matter for payments.

Distributed tracing is non-negotiable. A charge flows through 5–10 services; without traces, debugging “why did this customer get double-charged” is a 3-day investigation. With traces, it’s 20 minutes.

Key Takeaways

  • Make the ledger the source of truth. Balances are projections; never store them as canonical.
  • Idempotency is the only correctness guarantee that matters for money movement. Bake it into every endpoint, every queue consumer, every webhook.
  • Reduce PCI scope aggressively through hosted fields and tokenization. What your systems never see, they cannot leak.
  • Separate authorization from settlement with an event bus. They have different latency, reliability, and consistency requirements.
  • Treat reconciliation as a first-class service, not a quarterly SQL script. Daily, automated, and tied into incident response.
  • Defense in depth is non-negotiable: mTLS, rate limiting, anomaly detection, webhook signing, least-privilege IAM.
  • Observability is operational insurance. Track auth success rate, reconciliation delta, and p99 latency as SLOs with pages, not just dashboards.

Further Reading