Why state machines matter in payment systems
We've all seen payment bugs that shouldn't exist: a refund processed on a transaction that never captured, or a charge authorized twice because the state wasn't clear. Payment state machines: a practical guide for developers means building an explicit model of where each transaction can be at any moment, and more importantly, where it can go next. Without this foundation, you're debugging race conditions and impossible states for months.
The core insight is simple: a payment isn't just "done" or "pending"—it moves through discrete states, and only certain transitions are valid. When you enforce this rigorously, you eliminate entire classes of bugs before they reach production.
Understanding the canonical payment flow
Most payment systems follow a similar progression. A customer initiates a purchase, the payment processor authorizes the charge (reserving funds), then you capture it (actually moving money), and later you may refund it. Each step is a state, and each has specific preconditions.
Let's define our four core states:
- pending: Transaction created, awaiting authorization attempt.
- authorized: Funds reserved with the processor, not yet captured.
- captured: Money has moved. This is terminal for successful charges.
- refunded: Original charge returned to customer.
In a real system, you might add failed, declined, or partial_refund. The principle remains: each state has a clear meaning, and transitions follow business rules.
Rejecting invalid transitions
This is where discipline pays off. Not every state can transition to every other state. You can't refund a transaction that never authorized. You can't capture the same charge twice. By encoding these rules explicitly, you make impossible states actually impossible.
Here's how we structure this in TypeScript:
enum PaymentState {
PENDING = 'pending',
AUTHORIZED = 'authorized',
CAPTURED = 'captured',
REFUNDED = 'refunded',
FAILED = 'failed',
}
type StateTransition = {
from: PaymentState;
to: PaymentState;
};
const VALID_TRANSITIONS: StateTransition[] = [
{ from: PaymentState.PENDING, to: PaymentState.AUTHORIZED },
{ from: PaymentState.PENDING, to: PaymentState.FAILED },
{ from: PaymentState.AUTHORIZED, to: PaymentState.CAPTURED },
{ from: PaymentState.AUTHORIZED, to: PaymentState.FAILED },
{ from: PaymentState.CAPTURED, to: PaymentState.REFUNDED },
];
class PaymentStateMachine {
private currentState: PaymentState;
private transactionId: string;
constructor(transactionId: string, initialState: PaymentState = PaymentState.PENDING) {
this.transactionId = transactionId;
this.currentState = initialState;
}
canTransitionTo(nextState: PaymentState): boolean {
return VALID_TRANSITIONS.some(
(t) => t.from === this.currentState && t.to === nextState
);
}
transitionTo(nextState: PaymentState): void {
if (!this.canTransitionTo(nextState)) {
throw new Error(
`Invalid transition: ${this.currentState} → ${nextState} for txn ${this.transactionId}`
);
}
this.currentState = nextState;
}
getState(): PaymentState {
return this.currentState;
}
}
This approach is defensive. Every transition is validated before it happens. If a webhook arrives out of order or a bug sends an impossible command, the system rejects it loudly rather than silently corrupting state.
Logging every transition
State transitions are the story of your payment. When debugging, you need to know exactly when each transition occurred, what triggered it, and what the system state was. This isn't optional—it's your audit trail and your debugging lifeline.
Create a separate audit log table for every state change:
interface StateTransitionAudit {
transactionId: string;
fromState: PaymentState;
toState: PaymentState;
timestamp: Date;
triggeredBy: 'webhook' | 'manual' | 'scheduled';
webhookId?: string;
metadata?: Record<string, unknown>;
}
class PaymentStateMachineWithAudit extends PaymentStateMachine {
private auditLog: StateTransitionAudit[] = [];
async transitionTo(
nextState: PaymentState,
triggeredBy: string,
webhookId?: string
): Promise<void> {
const previousState = this.getState();
if (!this.canTransitionTo(nextState)) {
throw new Error(
`Invalid transition: ${previousState} → ${nextState}`
);
}
// Perform transition
super.transitionTo(nextState);
// Log it
const audit: StateTransitionAudit = {
transactionId: this.transactionId,
fromState: previousState,
toState: nextState,
timestamp: new Date(),
triggeredBy: triggeredBy as 'webhook' | 'manual' | 'scheduled',
webhookId,
metadata: {
userAgent: process.env.NODE_ENV,
},
};
this.auditLog.push(audit);
// Persist to database
await this.persistAudit(audit);
}
private async persistAudit(audit: StateTransitionAudit): Promise<void> {
// INSERT into payment_state_audit
// Always log to a table you can query later
}
}
Every transition gets recorded with the timestamp, who triggered it, and the old and new states. This becomes invaluable when investigating customer disputes or debugging race conditions. You can reconstruct the exact sequence of events.
Driving state from webhooks
This is the operational reality: your payment state is ultimately controlled by the payment processor (Stripe, Adyen, etc.), not your code. Webhooks tell you when the processor's state changed, and your job is to sync your local state machine to match.
The pattern is straightforward: when a webhook arrives, validate it, then ask your state machine if the transition is allowed. If yes, apply it and persist. If no, log the anomaly and alert your team.
interface StripeWebhook {
type: string;
data: {
object: {
id: string;
status: string;
charges?: {
data: Array<{ status: string; refunded: boolean }>;
};
};
};
}
async function handleStripeWebhook(payload: StripeWebhook): Promise<void> {
const transactionId = payload.data.object.id;
let stateMachine = await loadPaymentStateMachine(transactionId);
let targetState: PaymentState;
// Map Stripe's language to our state
switch (payload.type) {
case 'charge.authorized':
targetState = PaymentState.AUTHORIZED;
break;
case 'charge.captured':
targetState = PaymentState.CAPTURED;
break;
case 'charge.refunded':
targetState = PaymentState.REFUNDED;
break;
case 'charge.failed':
targetState = PaymentState.FAILED;
break;
default:
console.log(`Ignoring webhook type: ${payload.type}`);
return;
}
// Validate the transition
if (!stateMachine.canTransitionTo(targetState)) {
console.error(
`Impossible webhook transition: ${stateMachine.getState()} → ${targetState}. Webhook ID: ${payload.data.object.id}`
);
// Send alert to team, but don't crash
await alertOps(`Anomalous payment state for ${transactionId}`);
return;
}
// Apply the transition and persist
await stateMachine.transitionTo(targetState, 'webhook', payload.data.object.id);
await persistPaymentState(transactionId, stateMachine.getState());
}
This is defensive programming. You're not blindly trusting the webhook; you're validating that it makes sense given your current state. If Stripe's webhook arrives out of order or duplicated, your state machine catches it.
Common mistakes
Allowing state to drift from the source of truth. If you cache payment state in memory or in a fast cache layer without syncing it back to your audit log, you lose the single source of truth. Always persist state changes.
Not idempotencing webhook handlers. Networks are unreliable. The same webhook can arrive twice. Your transition logic must be idempotent: if you're already in the target state, acknowledge success and move on rather than erroring out.
Defining too many states. It's tempting to add states for every edge case: partially_refunded, pending_capture, authorization_expired. Start minimal. Add states only when you have distinct business logic that depends on them.
Ignoring partial refunds and multi-capture flows. Real payment systems are messier than our simple four-state model. You might refund part of a charge, or capture in tranches. Plan for this complexity upfront rather than bolting it on later.
Key takeaways
- Model payments as explicit state machines with discrete, named states and validated transitions.
- Reject invalid transitions with clear error messages—make impossible states actually impossible.
- Log every transition to a persistent audit table, including timestamp, trigger, and metadata.
- Drive state changes from webhook events, but validate each transition before applying it.
- Use your audit log as your debugging lifeline; it's the single source of truth for what happened and when.
- Start with a minimal state set (pending, authorized, captured, refunded) and add complexity only as needed.
- Make webhook handlers idempotent so duplicate events don't corrupt your state.






Comments
Loading comments…