Case studies
Case study: payment system
Moving money correctly, with payment intents, idempotency at every hop, a double-entry ledger, webhooks and reconciliation when the network lies. Start here for the payments map.
Payments are where "at-least-once delivery" becomes double charges. A checkout click, a flaky mobile network, a load balancer retry and a webhook replay can each look like a new request. The business needs something close to exactly-once money movement, built on unreliable networks and at-least-once messaging.
You rarely talk to card networks directly. A payment service provider (PSP), such as Stripe or Adyen, authorizes and settles with the banks. Your job is to model payment intents, stay idempotent, keep a trustworthy ledger, and reconcile when your records disagree with the PSP's.
Requirements
- Charge cards and wallets through a PSP. If you run a marketplace, also support refunds and seller payouts.
- Survive retries and timeouts without double-charging, and without silently dropping a successful charge.
- Keep a full audit trail: who moved what, when and why.
- Keep strongly consistent balances, using integer minor units such as cents. Never use floats.
- Reduce PCI scope, so card data stays with the PSP.
A first design leaves out full fraud models, multi-currency FX engines and bank licenses.
The main flow
A PSP exposes a payment intent: a record of one attempt to collect money, with a status that moves through a state machine. Your service creates the intent, confirms it, and learns the outcome, either now or later through a webhook. The details are in payment intents and states.
checkout → create intent → confirm (authorize) → succeeded | requires_action (3DS) | failed
↓
webhook payment_intent.succeeded → post ledgerConcepts in this topic
Read them in order. Each one builds on the last.
| Step | Concept | What it answers |
|---|---|---|
| 1 | Payment intents and states | How does a payment move through the PSP's states, and what if a call times out? |
| 2 | Idempotency at every hop | Where can a retry create a second charge, and how is each hop protected? |
| 3 | Double-entry ledger | How do you record money so the books always balance? |
| 4 | Webhooks and outbox | How do you receive PSP events safely, and make outgoing calls survive a crash? |
| 5 | Reconciliation | How do you prove your records match the PSP's? |
Keep card data out of your systems
Card numbers that touch your servers bring you into PCI DSS scope. Reduce that scope:
- Use the PSP's hosted fields, such as Stripe Elements or Adyen Drop-in, so card data goes from the browser straight to the PSP.
- Store only tokens, payment method IDs and customer IDs.
- Keep webhook and server APIs free of card numbers and CVV codes.
- Put API keys in a secrets vault, and keep card data out of logs.
Consistency and money
Use a relational primary, such as Postgres, for the accounts you debit. Use unique constraints for idempotency keys and journal IDs. Partition by merchant or customer, so one busy merchant does not lock the rest. Keep each journal's entries in one shard, so they commit together. Transfers that cross shards need a saga or an outbox, covered in distributed transactions.
Eventual consistency is fine for displays, such as a dashboard or an approximate available balance. Authorization of a payout must read strongly consistent balances.
Common mistakes
- A new idempotency key on each retry click. The retry then creates a second charge.
- Trusting the redirect alone. The browser may never return. The webhook is the source of truth.
- Marking an order paid before the ledger is posted. The books and the order can disagree.
- Floats for money.
0.1 + 0.2is not how cents work.
Key takeaways
- Model payments as intents with explicit states, and treat webhooks as the source of completion.
- Use idempotency keys on every mutating step, and reuse them on timeout retries.
- Keep immutable double-entry journals, and derive balances from them.
- Reconcile against PSP settlements on a schedule. Unexplained gaps are incidents.
- Store money as integer minor units, and tokenize cards to shrink PCI scope.
Further learning
Stripe's post on idempotency is the canonical mental model for safe retries:
Stripe: Designing robust and predictable APIs with idempotencyOnline payment processing for internet businesses. Stripe is a suite of payment APIs that powers commerce for businesses of all sizes.stripe.comSquare's Books service is a public write-up of an immutable double-entry store:
Square: Books, an immutable double-entry accounting database serviceTracking financial transactions at scaleSquare Corner Blog
Start with payment intents and states to see the flow in detail.
Related
Payment intents and states
How a PSP payment intent moves through its states, why every confirm can end in an unknown outcome, and why your own status column should be the one you trust.
Idempotency and retries
Making operations safe to retry, with idempotency keys, deduplicating consumers and jittered backoff, so at-least-once delivery never means a double charge.
Idempotency at every hop
How idempotency keys protect a payment at each boundary, from the client to your API, from your service to the PSP and from the PSP's webhooks back to you, and why the key row must share the payment's transaction.
Double-entry ledger
How a ledger records every money movement as balanced, immutable journals in integer cents, including refunds and payouts, and why balances should be derived from journals.
Distributed transactions
Keeping data consistent when one business operation spans several databases or services, using two-phase commit, sagas, the transactional outbox and reservations, and redrawing boundaries first.
Cash flow and the three statements
Why profit ≠ cash, how the balance sheet and cash flow statement work, deferred revenue from annual prepay, and how the three statements link.