Payments & Money APIs · Charging Safely

Payments as State Machines

Explicit states and transitions for orders and payments.

Model payments (and orders) as explicit state machines with allowed transitions, persisted state and history. A PSP payment object (e.g. PaymentIntent) has its own states; your order has business states. Map PSP events to order transitions idempotently, reject invalid transitions (e.g. refunded → paid), and record each transition with the triggering event ID. This makes retries, out-of-order webhooks and support investigations manageable.

Order payment states

Order state Entered when Next states
pending_payment Order created authorized, paid, payment_failed, cancelled
authorized Auth succeeded (manual capture) paid (captured), cancelled (voided), expired
paid Capture/charge succeeded fulfilled, refunded, partially_refunded, disputed
payment_failed Decline / failure pending_payment (retry), cancelled
refunded / partially_refunded Refund succeeded disputed (rare)
disputed Chargeback opened paid (won), refunded/lost
TRANSITIONS = {
    "pending_payment": {"authorized", "paid", "payment_failed", "cancelled"},
    "authorized": {"paid", "cancelled", "expired"},
    "paid": {"fulfilled", "refunded", "partially_refunded", "disputed"},
    "partially_refunded": {"refunded", "disputed"},
    "payment_failed": {"pending_payment", "cancelled"},
}

async def transition(db, order_id: str, to: str, *, event_id: str) -> bool:
    async with db.transaction():
        order = await db.fetchrow("SELECT state FROM orders WHERE id=$1 FOR UPDATE", order_id)
        if await db.fetchval("SELECT 1 FROM order_events WHERE event_id=$1", event_id):
            return False                                           # already applied (idempotent)
        if to not in TRANSITIONS.get(order["state"], set()):
            await log_invalid_transition(order_id, order["state"], to, event_id)
            return False
        await db.execute("UPDATE orders SET state=$2 WHERE id=$1", order_id, to)
        await db.execute("INSERT INTO order_events(order_id, event_id, from_state, to_state) VALUES ($1,$2,$3,$4)",
                         order_id, event_id, order["state"], to)
        return True

Common pitfalls

  • Boolean flags (is_paid, is_refunded) instead of states.
  • Updating state without locking (races between webhook and API).
  • No history of transitions.

Key points

  • Explicit states and allowed transitions
  • Idempotent, locked transitions
  • Persist transition history with event IDs

Further reading: Stripe: PaymentIntent lifecycle

Practice questions

Why model payments as a state machine instead of boolean flags?

Answer

Flags allow impossible combinations and hide history; explicit states with allowed transitions prevent invalid updates and make out-of-order events safe to handle.

How do you make applying a webhook-driven transition idempotent?

Answer

Record the PSP event ID with the transition in the same transaction and skip events already applied.

Open in the app to mark lessons complete, take notes and track your progress.