Filter concepts by levelShowing all levels.

System Design · Payment System Design

Payment System Design

The six blocks that make a payment flow correct, why a browser redirect proves nothing, and why payment state and fulfilment state are two machines

  • Idempotency keyswhat makes retrying an ambiguous timeout safe
  • Not the redirectforgeable, skippable, and sometimes early
  • Two state machinespayment and fulfilment move independently

1.Idempotency keys, immutable records, explicit states, webhooks, reconciliation

A payment flow is the standard example of a workflow you cannot make atomic: your database and the payment provider are two systems with no shared transaction, and money has already moved by the time you find out whether your own write succeeded. Six building blocks handle that, and they are not independent options — each one closes a gap the others leave open. An idempotency key, sent by you with the charge request, lets the provider recognise a repeated request as the same request and return the original result instead of charging again, which is what makes a retry after a timeout safe. Immutable transaction records mean you append what happened rather than updating a row in place, so a refund is a new row and the original charge is still readable — an audit trail you can reconcile against, rather than a current value you have to trust. Explicit payment states (`requires_payment_method`, `processing`, `succeeded`, `failed`) replace a boolean `paid` flag, so the very common in-between state has a name and the code has somewhere to put it. Webhook processing is how you learn the outcome of anything asynchronous, because a payment can complete minutes after the customer closed the tab. Reconciliation is a scheduled job that compares your records against the provider's and reports the differences, on the assumption that some will exist. And retry-safe operations means every step in the flow can run twice without a second effect — which is what the previous five blocks together are for.

Key features

  • Idempotency key: a caller-generated key sent with the charge; the provider returns the original result for a repeat instead of charging twice — this is what makes retrying an ambiguous timeout safe
  • Immutable transaction records: append a new row per event (authorised, captured, refunded) rather than updating a `paid` column, so history stays readable and reconcilable
  • Explicit payment states: a named state machine (requires payment method → processing → succeeded / failed) instead of a boolean, because "in progress" is the state most flows spend real time in
  • Webhook processing: the provider calls you when an asynchronous outcome is known; webhooks arrive out of order, more than once, and sometimes for a payment you have no record of yet
  • Reconciliation: a scheduled job comparing your records against the provider's, designed on the assumption that discrepancies exist rather than that they should not
  • Retry-safe operations: every step can run twice with one effect — the property the other five blocks exist to produce

Six building blocks and the specific failure each one closes

Six building blocks and the specific failure each one closes
BlockFailure it closesWhat happens without it
Idempotency keyA retry after a timeout on a charge whose result you never sawThe customer is charged twice; you find out via a chargeback
Immutable transaction recordsA dispute about what happened and in what orderThe current row says `refunded` and nothing says what the original charge was
Explicit payment statesA payment that is neither clearly paid nor clearly failedThe in-between state is encoded as "not paid", so the customer is asked to pay again
Webhook processingAn outcome that arrives after the customer leftAsynchronous methods appear to fail; delayed successes are never recorded
ReconciliationEverything the first four blocks still missedDiscrepancies accumulate silently until an accountant finds them
Retry-safe operationsAny step re-running after a partial failureA resumed workflow duplicates whichever steps already completed

Three webhook properties you must design for, not hope against

Three webhook properties you must design for, not hope against
PropertyConcrete caseRequired handling
Delivered more than onceThe provider retries because your 200 response was lostDedupe on the event id before applying any effect
Delivered out of order`payment.succeeded` arrives after `charge.refunded`Apply by state machine and event version, never by arrival order
Delivered before your own write is visibleThe webhook lands before your create-order transaction commitsLook up by the provider's id; if unknown, record and retry rather than discard
A charge, an ambiguous timeout, and a safe retry
Your service
Provider
Your database
  1. 1record intent (state: processing)
  2. 2POST charge, Idempotency-Key: order_8814
  3. 3connection times out — outcome unknownmoney may or may not have moved
  4. 4POST charge again, SAME idempotency key
  5. 5original charge returned, not a second one
  6. 6webhook: payment_intent.succeededmay arrive before or after the retry
  7. 7append event, advance state to succeeded
  1. Your service → Your database: record intent (state: processing)
  2. Your service → Provider: POST charge, Idempotency-Key: order_8814
  3. Provider → Your service: connection times out — outcome unknown (money may or may not have moved)
  4. Your service → Provider: POST charge again, SAME idempotency key
  5. Provider → Your service: original charge returned, not a second one
  6. Provider → Your service: webhook: payment_intent.succeeded (may arrive before or after the retry)
  7. Your service → Your database: append event, advance state to succeeded

Command

http
POST /v1/payment_intents HTTP/1.1
Host: api.stripe.com
Idempotency-Key: order_8814_attempt_1
Content-Type: application/x-www-form-urlencoded

amount=4200&currency=usd&customer=cus_...

# Same key again -> the original PaymentIntent is
# returned. No second charge is created.

Six blocks, each closing a gap the others leave: idempotency keys make retrying an ambiguous timeout safe; immutable event rows keep history reconcilable; explicit states give the in-between outcome a name; webhook processing carries asynchronous results, deduplicated on the provider's event id and claimed before the effect is applied; reconciliation is a scheduled job that assumes discrepancies exist; and retry safety is the property all five together produce. The hard case is not failure — it is not knowing.

2.A frontend redirect is not proof that a payment completed

After a customer pays on a provider's hosted page, their browser is redirected back to a success URL on your site. That redirect is a navigation event in a browser you do not control, and it is not a statement from the provider that money moved. Three things are wrong with treating it as one. It can be forged: the URL is visible in the address bar, and anyone can type it, bookmark it or share it, so a request to `/checkout/success?order=8814` proves only that someone requested that path. It can be missed: the customer closes the tab, loses signal, or the payment method completes asynchronously minutes later, so a genuinely successful payment produces no redirect at all. And it can arrive before the payment is final: some methods redirect while the charge is still pending, so the redirect is truthful about "the customer finished their part" and silent about "the money settled". The correct design uses the redirect for exactly one thing — deciding what to show the customer next — and takes the actual payment outcome from a server-side source: a webhook from the provider, or a server-to-server lookup of the payment by its id. Fulfilment, entitlement and receipts hang off that source, never off the redirect.

Key features

  • A redirect to your success URL is a browser navigation, not a message from the provider — it can be typed, bookmarked, shared or replayed by anyone
  • A genuinely successful payment can produce no redirect at all: the customer closes the tab, loses connectivity, or the method settles asynchronously after they leave
  • Some payment methods redirect while the charge is still pending, so the redirect can be truthful and still not mean the money settled
  • The authoritative sources are server-side: a signed webhook from the provider, or a server-to-server lookup of the payment by its provider id
  • Verify the webhook signature — an unsigned or unverified webhook endpoint is a forgeable URL exactly like the redirect it was meant to replace
  • Use the redirect only to choose what the customer sees next; hang fulfilment, entitlement and receipts off the server-side outcome

Three signals, and what each actually proves

Three signals, and what each actually proves
SignalProvesDoes not prove
Redirect to your success URLA browser requested that URLThat any payment was attempted, let alone completed
Signed webhook from the providerThe provider says this payment reached this stateThat you have processed it — you still dedupe and record it
Server-to-server lookup by payment idThe payment's state at the moment you askedThat it will not change later (refunds, disputes, async settlement)
Two paths carry two different kinds of truth
may confirmnever

Customer pays on hosted page

Browser redirect to /success

forgeable, skippable, possibly early

Decide what to display

thanks, or "confirming your payment"

Signed webhook to your server

the authoritative outcome

Server-to-server lookup by id

same authority, pull instead of push

Fulfil, grant entitlement, send receipt

  • Customer pays on hosted page
    • leads to Browser redirect to /success
    • leads to Signed webhook to your server
  • Browser redirect to /success — forgeable, skippable, possibly early
    • leads to Decide what to display
    • leads to Server-to-server lookup by id (may confirm)
    • on error, leads to Fulfil, grant entitlement, send receipt (never)
  • Decide what to display — thanks, or "confirming your payment"
  • Signed webhook to your server — the authoritative outcome
    • leads to Fulfil, grant entitlement, send receipt
  • Server-to-server lookup by id — same authority, pull instead of push
    • leads to Fulfil, grant entitlement, send receipt
  • Fulfil, grant entitlement, send receipt

Command

python
# The success route decides what to SHOW, nothing else
@app.get("/checkout/success")
def success(payment_intent_id: str):
    # server-to-server, not the query string
    intent = provider.retrieve(payment_intent_id)
    if intent.status == "succeeded":
        return render("thanks.html")
    return render("confirming.html")   # webhook will
                                       # settle it

The redirect tells you the customer thinks they paid; the webhook tells you the provider says they did. A redirect can be forged, missed entirely, or arrive while the charge is still pending, so use it only to decide what to display. Fulfilment, entitlement and receipts hang off a signature-verified webhook or a server-to-server lookup by payment id — and an unverified webhook endpoint is a forgeable URL exactly like the redirect it replaced.

3.Separating payment state from order and fulfilment state

A single `status` column on an order tries to hold two different lifecycles at once, and they do not line up. The payment lifecycle belongs to the provider and moves through states like requires-payment-method, processing, succeeded, failed, refunded, disputed. The fulfilment lifecycle belongs to you and moves through pending, allocated, packed, shipped, delivered, returned, cancelled. These progress independently: a payment can succeed while fulfilment has not started, fulfilment can be underway while a dispute is opened, and a refund can happen long after delivery. Squashing both into one enum forces you to invent hybrid values — `paid_but_not_shipped`, `shipped_refund_pending` — and the count of those values grows as the product of the two lifecycles, not their sum. Two state machines with an explicit link between them keeps each one small and independently correct, lets each move at its own speed, and makes the interesting questions answerable with a query: which orders are paid and unfulfilled, which are fulfilled and unpaid. The link is a rule you write down — typically "fulfilment starts when payment reaches succeeded" — rather than a shared column that pretends the two lifecycles are one.

Key features

  • The payment lifecycle is owned by the provider (requires payment method → processing → succeeded / failed → refunded / disputed); the fulfilment lifecycle is owned by you (pending → allocated → shipped → delivered / returned)
  • The two progress independently: paid-but-unfulfilled and fulfilled-but-disputed are both normal, not error states
  • A single `status` enum has to enumerate the product of both lifecycles, so its value count grows multiplicatively as either side gains a state
  • Two state machines plus an explicit transition rule ("fulfilment starts on payment succeeded") keeps each machine small and independently testable
  • Separation makes the operationally important questions plain queries: paid and unfulfilled, fulfilled and unpaid, refunded but not returned
  • Where a design genuinely has one lifecycle — a digital good granted at the instant of payment — one state machine is the right answer; the roadmap's "where appropriate" is doing real work

Two state machines, one link

Two state machines, one link
MachineOwned byStates
PaymentThe payment provider; you observe itrequires_payment_method · processing · succeeded · failed · refunded · disputed
FulfilmentYou; the provider knows nothing about itpending · allocated · packed · shipped · delivered · returned · cancelled
LinkA rule you write down and enforcefulfilment leaves `pending` only when payment is `succeeded`

Combinations that a single-enum design has no clean value for

Combinations that a single-enum design has no clean value for
PaymentFulfilmentWhat it means operationally
succeededpendingMoney taken, nothing allocated yet — the normal state right after checkout
processingpendingAsynchronous method still settling; do not ship, do not cancel
succeededshippedThe ordinary completed order
disputedshippedGoods are gone and the money is being clawed back — the case you most need to query for
refundeddeliveredA post-delivery refund; the return may or may not follow
One status column versus two state machines

One `status` enum

  • Values must cover every payment × fulfilment pair
  • Hybrid names appear: paid_but_not_shipped, shipped_refund_pending
  • Adding one payment state forces a new value per fulfilment state
  • "Paid and unfulfilled" needs a list of specific enum values, kept in sync by hand

Two state machines

  • Each machine has its own small, testable transition set
  • Adding a payment state adds one value, not a row of combinations
  • Independent progress is representable, because it is representable in the data
  • "Paid and unfulfilled" is a two-column WHERE clause
  • One `status` enum
    • Values must cover every payment × fulfilment pair
    • Hybrid names appear: paid_but_not_shipped, shipped_refund_pending
    • Adding one payment state forces a new value per fulfilment state
    • "Paid and unfulfilled" needs a list of specific enum values, kept in sync by hand
  • Two state machines
    • Each machine has its own small, testable transition set
    • Adding a payment state adds one value, not a row of combinations
    • Independent progress is representable, because it is representable in the data
    • "Paid and unfulfilled" is a two-column WHERE clause

Command

sql
-- two columns, two machines, one link rule
CREATE TABLE orders (
  id            uuid PRIMARY KEY,
  payment_state text NOT NULL,   -- provider's machine
  fulfil_state  text NOT NULL,   -- yours
  CHECK (fulfil_state = 'pending'
         OR payment_state = 'succeeded')
);

-- the question a single enum cannot answer cleanly
SELECT id FROM orders
 WHERE payment_state = 'disputed'
   AND fulfil_state IN ('shipped', 'delivered');

Payment state belongs to the provider, fulfilment state belongs to you, and they progress independently — paid-and-unfulfilled, disputed-and-delivered, refunded-and-not-yet-returned are all normal. One `status` column has to enumerate the product of both lifecycles; two columns keep each machine small and make the operational questions plain WHERE clauses. Then write the link between them down as a constraint, because splitting the machines removes the coupling the single enum was accidentally providing.

Remember

  • The hard case in payments is not failure — it is not knowing, and every building block exists to make "I do not know" recoverable.
  • An idempotency key identifies a logical operation, so generate it before the first attempt and reuse it on every retry.
  • Fulfilment hangs off a signature-verified webhook or a server-to-server lookup, never off a browser redirect.
  • Payment state belongs to the provider and fulfilment state belongs to you; one column cannot hold both lifecycles.

Pro tip

  • Claim a webhook event id with a unique insert before applying its effect — a crash between effect and acknowledgement is otherwise a duplicate refund.
  • Write the reconciliation job on the assumption that discrepancies exist; a job that only alerts on zero differences gets muted in its first week.

Next up

  • Notification System Design
  • Separating notification intent from delivery
  • Delivery state, retries and user preferences

Sources

  • Stripe — Idempotent requests
  • Stripe — Webhooks
  • Stripe — PaymentIntent lifecycle
  • Checked 2026-08-28

Spotted a mistake on this page? Tell us.