Skip to content
HeapByte — Senior Shopify Engineering Studio

Engineering · 21 October 2026

Shopify Fulfillment Orders API in ERP integrations: holds, line splits, and multi-warehouse routing

A European wholesale homeware and furniture distributor operates a central fulfillment hub in Amsterdam and a bulk logistics depot at the Port of Rotterdam. A high-volume B2B hospitality client submits an order on the distributor's Shopify Plus storefront for 12 solid oak dining chairs, 2 custom walnut boardroom tables, and 24 upholstered cushions. When the order lands in Shopify, an ERP integration connector running legacy architecture immediately attempts to process the order. It constructs an old-style HTTP POST to /admin/api/orders/{id}/fulfillments.json. The request instantly crashes. In the distributor's Exact Online ERP, the customer's trade credit limit is currently exceeded pending bank wire reconciliation, and while the chairs and cushions are physically present in Amsterdam, the 2 walnut tables are stored 70 kilometres away in Rotterdam.

8 min read
Written by the HeapByte engineering team

Shopify fulfilment Orders erp

The death of the legacy Fulfillment API

In modern Shopify architecture, an ERP integration connector cannot treat fulfillment as an instant, stateless HTTP POST against an order ID. Shopify has permanently deprecated the legacy Fulfillment API in favor of the stateful Fulfillment Orders API.

A Fulfillment Order represents a discrete unit of work assigned to a specific physical location. When an order is created, Shopify generates child fulfillment orders that govern routing, holds, and split dispatch lifecycles.

The Fulfillment Order state machine

A Shopify Fulfillment Order progresses through strict lifecycle states: OPEN, ON_HOLD, IN_PROGRESS, and CLOSED. Rather than immediately generating shipping labels, an external ERP acts as an active participant in this state machine.

The connector evaluates business rules sequentially: holding orders for credit review, routing lines to stocking warehouses via moves, and isolating backorders through automated line splits.

Orchestrating B2B credit holds: fulfillmentOrderHold

In high-volume B2B distribution, customer credit limits frequently require holding order fulfillment until trade accounts are reconciled. If an ERP does not inform Shopify, warehouse staff print picking tickets and dispatch goods before credit clearance.

By emitting fulfillmentOrderHold with standardized reasons (AWAITING_PAYMENT, HIGH_RISK_OF_FRAUD, INVENTORY_OUT_OF_STOCK), the connector blocks physical dispatch while keeping customer service fully informed inside Shopify Admin.

Multi-location inventory re-routing: fulfillmentOrderMove

When Shopify's default order routing assigns an order to a location that lacks stock, the connector calls fulfillmentOrderMove to transfer the fulfillment order to an alternate warehouse division that holds full inventory.

This cross-warehouse move avoids cancelling orders, editing customer line items, or creating orphan records in the ERP.

Automated backorder splitting: fulfillmentOrderSplit

When an order contains both in-stock and out-of-stock items, fulfillmentOrderSplit divides the fulfillment order into two independent work units: immediately shippable items move to a new open fulfillment order for prompt dispatch, while backordered lines remain isolated under an inventory hold.

ts
import assert from "node:assert/strict";

export type FulfillmentOrderStatus = "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "SCHEDULED" | "CLOSED";
export type HoldReason = "AWAITING_PAYMENT" | "HIGH_RISK_OF_FRAUD" | "INVENTORY_OUT_OF_STOCK" | "OTHER";

export interface FulfillmentOrderLineItem {
  id: string;
  sku: string;
  quantity: number;
  remainingQuantity: number;
}

export interface FulfillmentOrder {
  id: string; // e.g. "gid://shopify/FulfillmentOrder/1001"
  orderId: string;
  assignedLocationId: string; // e.g. "gid://shopify/Location/201"
  status: FulfillmentOrderStatus;
  isHeld: boolean;
  holdReason?: HoldReason;
  lineItems: FulfillmentOrderLineItem[];
}

export interface ERPWarehouseDivision {
  locationId: string;
  divisionCode: string; // e.g. "WH-AMS" (Amsterdam Main), "WH-ROT" (Rotterdam Port)
  stockBySku: Record<string, number>;
}

export interface ERPCustomerAccount {
  customerId: string;
  creditStatus: "APPROVED" | "ON_HOLD";
  creditLimitExceeded: boolean;
}

export interface ReconcileFulfillmentInput {
  fulfillmentOrder: FulfillmentOrder;
  warehouses: ERPWarehouseDivision[];
  customerAccount?: ERPCustomerAccount;
  actionQueue: ("CHECK_CREDIT" | "ROUTE_INVENTORY" | "CONFIRM_DISPATCH")[];
  dispatchTracking?: {
    carrier: string;
    trackingNumber: string;
    trackingUrl?: string;
  };
}

export type FulfillmentMutation =
  | { type: "fulfillmentOrderHold"; fulfillmentOrderId: string; reason: HoldReason; notes: string }
  | { type: "fulfillmentOrderReleaseHold"; fulfillmentOrderId: string }
  | { type: "fulfillmentOrderMove"; fulfillmentOrderId: string; newLocationId: string }
  | {
      type: "fulfillmentOrderSplit";
      fulfillmentOrderId: string;
      fulfillmentOrderLineItems: { id: string; quantity: number }[];
    }
  | {
      type: "fulfillmentCreateV2";
      fulfillmentOrderId: string;
      lineItems: { id: string; quantity: number }[];
      tracking: { carrier: string; trackingNumber: string; trackingUrl?: string };
    };

export interface ReconcileFulfillmentResult {
  mutations: FulfillmentMutation[];
  resultingStatus: FulfillmentOrderStatus;
  resultingLocationId: string;
  errors: string[];
  warnings: string[];
}

export function reconcileFulfillmentOrderLifecycle(input: ReconcileFulfillmentInput): ReconcileFulfillmentResult {
  const mutations: FulfillmentMutation[] = [];
  const errors: string[] = [];
  const warnings: string[] = [];

  const { fulfillmentOrder, warehouses, customerAccount, actionQueue, dispatchTracking } = input;

  if (fulfillmentOrder.status === "CLOSED") {
    errors.push(`Cannot reconcile closed fulfillment order ${fulfillmentOrder.id}`);
    return {
      mutations: [],
      resultingStatus: "CLOSED",
      resultingLocationId: fulfillmentOrder.assignedLocationId,
      errors,
      warnings,
    };
  }

  let currentStatus: FulfillmentOrderStatus = fulfillmentOrder.status;
  let currentLocationId: string = fulfillmentOrder.assignedLocationId;
  let isCurrentlyHeld: boolean = fulfillmentOrder.isHeld;

  for (const action of actionQueue) {
    if (action === "CHECK_CREDIT") {
      if (customerAccount && (customerAccount.creditStatus === "ON_HOLD" || customerAccount.creditLimitExceeded)) {
        if (!isCurrentlyHeld) {
          mutations.push({
            type: "fulfillmentOrderHold",
            fulfillmentOrderId: fulfillmentOrder.id,
            reason: "AWAITING_PAYMENT",
            notes: "ERP Credit check failed: customer account credit limit exceeded.",
          });
          currentStatus = "ON_HOLD";
          isCurrentlyHeld = true;
        }
      } else if (isCurrentlyHeld && fulfillmentOrder.holdReason === "AWAITING_PAYMENT") {
        mutations.push({
          type: "fulfillmentOrderReleaseHold",
          fulfillmentOrderId: fulfillmentOrder.id,
        });
        currentStatus = "OPEN";
        isCurrentlyHeld = false;
      }
    }

    if (action === "ROUTE_INVENTORY") {
      if (isCurrentlyHeld) {
        warnings.push("Inventory routing skipped while fulfillment order is on hold.");
        continue;
      }

      const currentLocationWarehouse = warehouses.find((w) => w.locationId === currentLocationId);
      const totalRequiredItems = fulfillmentOrder.lineItems.reduce((acc, l) => acc + l.quantity, 0);

      let currentLocationAvailable = 0;
      const unfulfillableLines: FulfillmentOrderLineItem[] = [];
      const fulfillableLines: FulfillmentOrderLineItem[] = [];

      for (const line of fulfillmentOrder.lineItems) {
        const stock = currentLocationWarehouse?.stockBySku[line.sku] ?? 0;
        if (stock >= line.quantity) {
          currentLocationAvailable += line.quantity;
          fulfillableLines.push(line);
        } else if (stock > 0) {
          fulfillableLines.push({ ...line, quantity: stock });
          unfulfillableLines.push({ ...line, quantity: line.quantity - stock });
        } else {
          unfulfillableLines.push(line);
        }
      }

      if (currentLocationAvailable === totalRequiredItems) {
        continue;
      }

      const alternateWarehouseWithFullStock = warehouses.find((w) => {
        if (w.locationId === currentLocationId) return false;
        return fulfillmentOrder.lineItems.every((line) => (w.stockBySku[line.sku] ?? 0) >= line.quantity);
      });

      if (alternateWarehouseWithFullStock) {
        mutations.push({
          type: "fulfillmentOrderMove",
          fulfillmentOrderId: fulfillmentOrder.id,
          newLocationId: alternateWarehouseWithFullStock.locationId,
        });
        currentLocationId = alternateWarehouseWithFullStock.locationId;
      } else if (fulfillableLines.length > 0 && unfulfillableLines.length > 0) {
        mutations.push({
          type: "fulfillmentOrderSplit",
          fulfillmentOrderId: fulfillmentOrder.id,
          fulfillmentOrderLineItems: fulfillableLines.map((l) => ({
            id: l.id,
            quantity: l.quantity,
          })),
        });
        warnings.push(
          `Fulfillment order split into immediate dispatch (${fulfillableLines.length} lines) and backorder (${unfulfillableLines.length} lines).`
        );
      } else {
        mutations.push({
          type: "fulfillmentOrderHold",
          fulfillmentOrderId: fulfillmentOrder.id,
          reason: "INVENTORY_OUT_OF_STOCK",
          notes: "ERP stock depletion: no warehouse division has available inventory.",
        });
        currentStatus = "ON_HOLD";
        isCurrentlyHeld = true;
      }
    }

    if (action === "CONFIRM_DISPATCH") {
      if (isCurrentlyHeld) {
        errors.push("Cannot confirm dispatch on a held fulfillment order. Release hold first.");
        continue;
      }
      if (!dispatchTracking) {
        errors.push("Missing dispatch tracking details for fulfillment confirmation.");
        continue;
      }

      mutations.push({
        type: "fulfillmentCreateV2",
        fulfillmentOrderId: fulfillmentOrder.id,
        lineItems: fulfillmentOrder.lineItems.map((l) => ({ id: l.id, quantity: l.quantity })),
        tracking: dispatchTracking,
      });
      currentStatus = "CLOSED";
    }
  }

  return {
    mutations,
    resultingStatus: currentStatus,
    resultingLocationId: currentLocationId,
    errors,
    warnings,
  };
}
Production TypeScript fulfillment order state machine reconciler with credit gating, cross-location moves, and backorder splitting.

Webhook race conditions and idempotent dispatch

Always bind fulfillment mutations to specific GraphQL fulfillmentOrderIds rather than generic order IDs. Subscribing to fulfillment_orders/hold_added and hold_released webhooks ensures physical warehouse pick-lists freeze immediately when administrative holds are applied.

Checking that isHeld is false prior to issuing fulfillmentCreateV2 guarantees that no dispatch mutation is ever executed against a paused fulfillment order.

When this needs an engineer

You do not need a custom build if your business ships from a single domestic stockroom using manual labels. You need an engineer when logistics span multiple warehouses and business systems: when trade credit limits in your ERP must pause physical warehouse dispatch; when multi-location inventory requires automated routing and line splitting; and when third-party logistics partners demand automated ASN intake and container milestone tracking.

Review our work in Shopify ERP and CRM integration, explore the inventory reservation case study and order availability dashboard case study, or inspect our B2B operations case study collection.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.