Implement a reliable order state machine that moves orders from pending through payment, fulfillment, and delivery with webhook-driven transitions
Every ecommerce order follows a lifecycle: placed → payment confirmed → fulfillment started → shipped → delivered. Shopify, WooCommerce, and BigCommerce each implement this as a built-in order status system with webhook events at each transition. The goal is to configure these transitions correctly, wire your 3PL or fulfillment system to the right webhooks, and automate side effects (confirmation emails, inventory deduction, tracking notifications) at each stage.
Custom state machine code is only needed for headless storefronts or complex multi-step B2B workflows that platforms cannot handle natively.
| Platform | Order Statuses | Transition Mechanism |
|---|---|---|
| Shopify | Open → Partially Fulfilled → Fulfilled → Cancelled; Financial: Pending → Authorized → Paid → Refunded | Shopify Admin + webhooks; automated by Shopify Payments and fulfillment apps |
| WooCommerce | Pending → Processing → On Hold → Completed → Cancelled → Refunded → Failed | WooCommerce status system; transitions via admin, plugins, or wc_update_order_status() |
| BigCommerce | Pending → Awaiting Payment → Awaiting Fulfillment → Awaiting Shipment → Shipped → Completed + others | BigCommerce Admin + Orders API webhooks |
| Custom / Headless | Define your own state machine | Build with explicit transition validation |
Configure payment → order confirmation flow:
Set up automatic fulfillment for digital products:
Connect a 3PL or fulfillment center:
Manual fulfillment workflow:
Set up order webhooks for external systems (ERP, etc.):
Understand the default order flow:
Configure automatic status transitions:
Connect a shipping/fulfillment plugin:
Custom status transitions via hooks:
// In functions.php or a custom plugin — auto-complete virtual orders
add_action('woocommerce_payment_complete', 'auto_complete_virtual_orders');
function auto_complete_virtual_orders($order_id) {
$order = wc_get_order($order_id);
if ($order && $order->get_status() === 'processing') {
$all_virtual = true;
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product->is_virtual()) { $all_virtual = false; break; }
}
if ($all_virtual) {
$order->update_status('completed', 'All virtual items — auto-completed.');
}
}
}
Order webhooks: Install WooCommerce Webhooks (built-in) — go to WooCommerce → Settings → Advanced → Webhooks and add webhooks for order created, updated, and deleted events.
Configure order status flow:
Connect a fulfillment provider:
Order webhooks: Go to Settings → Advanced Settings → WebHooks and add webhooks for order status changes. These are used by integrations like ERPs and accounting systems.
For custom storefronts, implement a state machine with explicit transition validation:
// Valid order state transitions
const VALID_TRANSITIONS = {
pending: ['confirmed', 'cancelled'],
confirmed: ['processing', 'cancelled'],
processing: ['shipped', 'cancelled'],
shipped: ['delivered', 'returned'],
delivered: ['returned', 'refunded'],
cancelled: ['refunded'],
};
async function transitionOrder(orderId, newStatus, metadata = {}) {
return db.$transaction(async (tx) => {
const order = await tx.orders.findUnique({ where: { id: orderId } });
if (!VALID_TRANSITIONS[order.status]?.includes(newStatus)) {
throw new Error(`Invalid transition: ${order.status} → ${newStatus}`);
}
const updated = await tx.orders.update({
where: { id: orderId },
data: { status: newStatus, [`${newStatus}At`]: new Date() },
});
// Append-only event log for audit trail
await tx.orderEvents.create({
data: { orderId, fromStatus: order.status, toStatus: newStatus, triggeredBy: metadata.triggeredBy ?? 'system' },
});
return updated;
});
}
// Wire to Stripe webhook — payment confirmation drives the transition
async function onPaymentSucceeded(paymentIntent) {
const orderId = paymentIntent.metadata.order_id;
const order = await db.orders.findUnique({ where: { id: orderId } });
// Idempotency — ignore if already confirmed
if (order.status !== 'pending') return;
await transitionOrder(orderId, 'confirmed', { triggeredBy: 'stripe_webhook' });
await sendOrderConfirmationEmail(orderId);
await deductInventory(orderId);
}
Side effects at each transition (run outside the DB transaction to avoid blocking):
Duplicate webhook delivery: All major payment processors can deliver the same webhook multiple times. Always check the current order status before processing a transition:
if (order.status !== 'pending') return; // Already processed
Inventory deduction timing:
Only deduct inventory after payment is confirmed (the confirmed transition), not when the order is placed. If a payment fails, you do not want inventory reserved indefinitely.
Order cancellation with partial fulfillment: If some items are shipped and others are not, platforms handle this differently. On Shopify, you can partially cancel unfulfilled line items while keeping the fulfilled items. On WooCommerce, install the WooCommerce Cancel Abandoned Order or manage this via the WooCommerce REST API.
order.paid on Shopify, Processing on WooCommerce), not on order creation| Problem | Solution |
|---|---|
| Order confirmed twice on Shopify due to duplicate webhook | Shopify deduplicates with an idempotency header; on your end, check order.financial_status !== 'paid' before processing |
| WooCommerce order stuck in "Pending payment" after PayPal payment | PayPal IPN (Instant Payment Notification) must be enabled in your PayPal account; verify the IPN URL in WooCommerce → Settings → Payments → PayPal |
| Inventory not deducted until order is manually completed | Move inventory deduction to the payment confirmed webhook, not the fulfillment webhook |
| 3PL not receiving new orders automatically | Verify the fulfillment app is subscribed to the correct order status — most 3PL apps pull "Processing" (WooCommerce) or "Unfulfilled + Paid" (Shopify) |
| Order status webhooks not firing | Check webhook registration in Shopify → Settings → Notifications → Webhooks or WooCommerce → Settings → Advanced → Webhooks and verify the endpoint is returning 200 |