Order State Machine Design 2026: Modelling Order Lifecycles in Digital Goods
The order state machine is the heart of any B2B fulfilment. Correct design makes the system observable, debuggable and audit-friendly. Bad design breeds impossible states and race conditions you can chase for months. This article is a detailed guide to designing an FSM for digital-goods orders, aligned with the FoxReload order lifecycle.
1. Six core states
The FoxReload API returns these order statuses: active, paid, processing, completed, cancelled, failed. Your internal FSM should mirror them:
enum OrderState {
ACTIVE = 'active', // created, awaiting payment
PAID = 'paid', // payment confirmed, queued for fulfilment
PROCESSING = 'processing', // supplier API called, awaiting result
COMPLETED = 'completed', // success, codes in externalData
CANCELLED = 'cancelled', // cancelled — see cancelReason
FAILED = 'failed', // terminal — fulfilment failed
}
Allowed transitions (graph):
active → paid | cancelled | failed
paid → processing | cancelled | failed
processing → completed | cancelled | failed
completed → (terminal)
cancelled → (terminal)
failed → (terminal)
Any other transition (e.g., completed → processing) is invalid and must throw.
The cancelReason field on a cancelled order is one of: payment_failure, payment_expiration, user_request, or null.
2. Invariants
An invariant is a property always true regardless of state. For an order:
total_amount > 0paid_at IS NULL⇔state ∈ {active, cancelled, failed}completed_at IS NULL⇔state ≠ completedcodes IS NULL OR codes = []⇔state ≠ completedcancel_reason IS NOT NULL⇒state = cancelled
Check invariants both inside the FSM transition handler and in the DB via CHECK constraints:
ALTER TABLE orders ADD CONSTRAINT chk_completed_codes
CHECK (state != 'completed' OR codes IS NOT NULL);
ALTER TABLE orders ADD CONSTRAINT chk_cancel_reason
CHECK (cancel_reason IS NULL OR state = 'cancelled');
3. XState or enum+switch?
For small/medium projects, enum + switch is enough:
async function transition(order: Order, event: OrderEvent): Promise<Order> {
const next = nextState(order.state, event.type);
if (!next) throw new Error(`invalid transition ${order.state} + ${event.type}`);
return db.tx(async (tx) => {
const updated = await tx.orders.update(order.id, {
state: next,
version: order.version + 1, // optimistic lock
}, { where: { version: order.version } });
await tx.audit.insert({
event_id: randomUUID(),
order_id: order.id,
from_state: order.state,
to_state: next,
actor: event.actor,
timestamp: new Date(),
});
return updated;
});
}
For complex flows (multi-supplier, partial-delivery, fraud-hold) XState provides a visual diagram and hierarchical states.
4. Polling the FoxReload API for state updates
FoxReload does not push webhooks. Your FSM advances by polling GET /api/orders/{order_id} and syncing the returned status into your local state:
async function syncOrderState(localOrder: Order, apiKey: string): Promise<Order> {
const res = await fetch(
`https://public-api.foxreload.com/api/orders/${localOrder.foxreloadOrderId}`,
{ headers: { 'X-API-Key': apiKey } },
);
const remote = await res.json();
if (remote.status !== localOrder.state) {
return transition(localOrder, { type: remote.status, actor: 'system' });
}
return localOrder;
}
Run this in a polling loop with exponential backoff until the order reaches a terminal state.
5. Implementation comparison
| Approach | LoC | Visualisable | Type-safe | Performance |
|---|---|---|---|---|
| Boolean flags | 50 | No | No | Best |
| Enum + switch | 150 | No | Strong | Best |
| XState | 200+ | Yes (Inspector) | Strong | Good |
| Temporal workflow | 500+ | Yes | Strong | Good (async) |
For most FoxReload integrations, enum+switch is sufficient. The state transitions are simple (strictly forward, no branching beyond terminal states), and the polling pattern maps cleanly to a single background job per order.
CTA
The FoxReload API exposes the current order status and per-item externalData codes via GET /api/orders/{id}. Poll this to drive your FSM transitions. Get access.
