The bug usually sounds like a support ticket about timing.
A customer pays. Your warehouse app sees order.paid and starts picking. Shopify later sends order.fulfilled, but your dashboard already marked the order complete. Or worse: you never start fulfillment because you waited for the wrong webhook, even though payment cleared hours ago.
Nothing is wrong with the first API call. The order exists. Payment succeeded. Your handler is reacting to the wrong lifecycle moment.
Paid, fulfilled, and closed are different gates
Shopify order webhooks describe signals, not one boolean called "done."
| Webhook | What changed | What it is not |
|---|---|---|
order.paid | Payment captured or authorized per your shop settings | Proof that items shipped |
order.fulfilled | Fulfillment records exist for line items | Proof that the order is archived/closed |
orders/updated | Some field on the order changed | A guarantee about which field changed |
If your app gates warehouse work, customer emails, or access on a single event, it will eventually act too early or too late.
The handler that ships too early
The tempting handler listens only for payment:
type ShopifyOrderPaidWebhook = {
id: number;
financial_status: "paid" | "partially_paid" | "pending";
fulfillment_status: "fulfilled" | "partial" | null;
line_items: Array<{ id: number; quantity: number }>;
};
export async function handleOrderPaid(event: ShopifyOrderPaidWebhook) {
await warehouseQueue.enqueue({
shopifyOrderId: event.id,
lineItems: event.line_items,
});
await customerEmail.send({
template: "order_complete",
orderId: event.id,
});
}
That looks reasonable until partial fulfillment enters the picture.
order.paid means money cleared under your shop's payment settings. It does not mean every line item has a fulfillment record. If you send "your order is on the way" here, you will eventually email customers before anything shipped.
Separate payment, fulfillment, and terminal state
Treat each webhook as a reason to reconcile order state — not as the whole state machine.
type ShopifyOrderSignal = {
id: number;
};
export async function handleShopifyOrderWebhook(event: ShopifyOrderSignal) {
const order = await shopify.rest.Order.find({
session,
id: event.id,
});
await orderStore.reconcile({
shopifyOrderId: order.id,
financialStatus: order.financial_status,
fulfillmentStatus: order.fulfillment_status,
closedAt: order.closed_at,
cancelledAt: order.cancelled_at,
lineItems: order.line_items.map((item) => ({
id: item.id,
fulfillableQuantity: item.fulfillable_quantity,
})),
});
if (shouldStartWarehouseWork(order)) {
await warehouseQueue.enqueueFromOrder(order);
}
if (shouldSendShippedEmail(order)) {
await customerEmail.sendShippedNotice(order);
}
}
shouldStartWarehouseWork might key off financial_status === "paid" and remaining fulfillable quantity.
shouldSendShippedEmail should key off fulfillment records, not payment alone.
That split is the whole bug class.
Partial fulfillment makes this worse
Real shops rarely jump from unpaid to fully fulfilled in one webhook.
Common sequence:
order.createdorder.paid- warehouse creates a partial fulfillment
order.fulfilledororders/updated- remaining items fulfill later
- order eventually closes
If your local model stores one status string like "complete" on order.paid, partial fulfillment will desync your support queue from Shopify Admin.
Store at least:
- Shopify order ID
- financial status
- fulfillment status
- per-line fulfillable quantity
- last Shopify
updated_at - whether your app already sent each customer-facing message
The last flag matters when Shopify retries webhooks or your worker processes the same paid event twice.
The test most examples skip
Most Shopify examples stop at order creation:
const order = await shopify.rest.Order.save({
session,
line_items: [{ variant_id: 123, quantity: 1 }],
});
expect(order.id).toBeDefined();
That only proves you can create an order.
The test that catches production drift is:
- create the order
- mark it paid (or simulate the payment transition your shop uses)
- read the order back before enqueueing warehouse work
- create fulfillment
- read the order back again before sending a shipped email
That is the flow your app needs when webhooks arrive out of order, retry, or not at all.
How to test this without a live shop
For Shopify order automation, the finish line is not "webhook received." It is "the app can prove which lifecycle state it is about to act on."
Use FetchSandbox's Shopify Admin sandbox to exercise order creation and webhook-shaped transitions before wiring production handlers. The point is not to replace Shopify's real fulfillment flow. The point is to make the paid-vs-fulfilled split obvious before order.paid starts triggering the wrong side effects.
Related reads: