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.
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,
};
}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.
