Skip to main content

Api

Shopify order paid is not fulfilled, and fulfilled is not closed

A common Shopify webhook bug: your app starts fulfillment or marks an order done on order.paid, but paid, fulfilled, and closed are different lifecycle states.

FetchSandbox EngineeringFetchSandbox Engineering

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."

WebhookWhat changedWhat it is not
order.paidPayment captured or authorized per your shop settingsProof that items shipped
order.fulfilledFulfillment records exist for line itemsProof that the order is archived/closed
orders/updatedSome field on the order changedA 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:

  1. order.created
  2. order.paid
  3. warehouse creates a partial fulfillment
  4. order.fulfilled or orders/updated
  5. remaining items fulfill later
  6. 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:

  1. create the order
  2. mark it paid (or simulate the payment transition your shop uses)
  3. read the order back before enqueueing warehouse work
  4. create fulfillment
  5. 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: