The receiving race condition: why gross stock sync strands backorders
In high-volume omnichannel operations, stock sync is rarely instantaneous. Between pallet offloading, warehouse barcode verification, ERP posting, and Shopify API webhooks, operational lag is inevitable.
When merchants rely on naive ERP connectors, incoming quantities are synced directly to public storefront availability, forcing backordered customers and live shoppers to fight over the same physical units.
- Customer Trust Erosion: Backordered customers who paid upfront weeks earlier receive delayed notifications while new shoppers receive next-day delivery.
- WMS Allocation Collisions: If both the ERP and Shopify attempt to assign stock independently, physical inventory is double-counted. Pickers arrive at warehouse racking only to find empty pallet slots.
- B2B Contractual Penalties: Wholesale and retail trade stockists bound by strict Service Level Agreements (SLAs) face delayed dispatch penalties because their reserved stock was consumed by direct-to-consumer impulse buys.
The inbound event lifecycle: from ERP GoodsReceipt to Shopify FulfillmentOrders
Modern Shopify inventory management revolves around the Fulfillment Orders API (`gid://shopify/FulfillmentOrder/`). Unlike legacy fulfillment models that treated orders as static records, Fulfillment Orders represent the discrete units of work required to pick, pack, and dispatch goods across physical locations.
When a customer places a pre-order or backorder, their order line is assigned to a `FulfillmentOrder` and locked with an explicit `fulfillmentOrderHold` (reason: `INVENTORY_OUT_OF_STOCK`).
When the ERP confirms a `GoodsReceipt`, the inbound engine intercepts the event, queries all held `FulfillmentOrder` objects carrying the arriving SKUs, allocates inventory, and executes hold release mutations before touching storefront inventory.
Priority-ranked allocation: B2B wholesale contracts versus FIFO retail backorders
When an inbound shipment is smaller than the total volume of waiting backorders, arriving stock must be allocated strictly by business rules.
B2B trade accounts, wholesale stockists, and corporate contract clients typically order in bulk and operate under binding purchase agreements. Allocating arriving inventory to B2B priority tiers first protects core commercial relationships and avoids breach-of-contract penalties.
Within each tier, orders are sorted strictly by FIFO (First-In, First-Out) `createdAt` timestamps. The customer who waited longest receives their goods first, ensuring fairness and predictable delivery schedules.
import assert from "node:assert/strict";
export type CustomerTradeTier = "B2B_PRIORITY" | "RETAIL_STANDARD";
export interface PendingBackorderLine {
orderId: string;
orderName: string; // e.g. "#10842"
fulfillmentOrderId: string; // "gid://shopify/FulfillmentOrder/1001"
customerTier: CustomerTradeTier;
createdAt: string; // ISO 8601 timestamp
sku: string;
quantityBackordered: number;
isHeld: boolean;
}
export interface InboundReceiptItem {
receiptId: string; // Exact Online GoodsReceipt ID e.g. "GR-2026-8841"
sku: string;
locationId: string; // "gid://shopify/Location/9001"
quantityReceived: number;
}
export interface OrderAllocationResult {
orderId: string;
orderName: string;
fulfillmentOrderId: string;
sku: string;
quantityAllocated: number;
remainingBackordered: number;
requiresHoldRelease: boolean;
}
export interface InboundReconciliationResult {
status: "PROCESSED" | "ALREADY_PROCESSED" | "INVALID_INPUT";
receiptId: string;
sku: string;
quantityReceived: number;
totalBackordersAllocated: number;
netStorefrontSurplusInventory: number;
allocations: OrderAllocationResult[];
holdReleaseMutations: Array<{
fulfillmentOrderId: string;
action: "fulfillmentOrderReleaseHold";
}>;
shopifyInventoryAdjustmentDelta: number;
}
export interface ReconcileInboundInput {
inboundItem: InboundReceiptItem;
pendingOrders: PendingBackorderLine[];
processedReceiptHistory?: Set<string>;
}
export function reconcileInboundGoodsReceipt(input: ReconcileInboundInput): InboundReconciliationResult {
const { inboundItem, pendingOrders, processedReceiptHistory } = input;
// 1. Idempotency Check: Prevent duplicate receipt processing
if (processedReceiptHistory && processedReceiptHistory.has(inboundItem.receiptId)) {
return {
status: "ALREADY_PROCESSED",
receiptId: inboundItem.receiptId,
sku: inboundItem.sku,
quantityReceived: inboundItem.quantityReceived,
totalBackordersAllocated: 0,
netStorefrontSurplusInventory: 0,
allocations: [],
holdReleaseMutations: [],
shopifyInventoryAdjustmentDelta: 0,
};
}
// 2. Input Validation
if (inboundItem.quantityReceived <= 0 || !inboundItem.sku || !inboundItem.locationId) {
return {
status: "INVALID_INPUT",
receiptId: inboundItem.receiptId,
sku: inboundItem.sku,
quantityReceived: inboundItem.quantityReceived,
totalBackordersAllocated: 0,
netStorefrontSurplusInventory: 0,
allocations: [],
holdReleaseMutations: [],
shopifyInventoryAdjustmentDelta: 0,
};
}
// 3. Filter pending orders matching the received SKU
const matchingOrders = pendingOrders.filter(
(order) => order.sku === inboundItem.sku && order.quantityBackordered > 0,
);
// 4. Sort orders by Priority:
// Precedence: B2B_PRIORITY first, then RETAIL_STANDARD
// Secondary sort: FIFO order date (earliest createdAt first)
const sortedOrders = [...matchingOrders].sort((a, b) => {
if (a.customerTier !== b.customerTier) {
return a.customerTier === "B2B_PRIORITY" ? -1 : 1;
}
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
// 5. Sequentially allocate physical stock
let remainingStockToAllocate = inboundItem.quantityReceived;
let totalBackordersAllocated = 0;
const allocations: OrderAllocationResult[] = [];
const holdReleaseMutations: Array<{
fulfillmentOrderId: string;
action: "fulfillmentOrderReleaseHold";
}> = [];
for (const order of sortedOrders) {
if (remainingStockToAllocate <= 0) break;
const allocatedQuantity = Math.min(order.quantityBackordered, remainingStockToAllocate);
const remainingBackordered = order.quantityBackordered - allocatedQuantity;
remainingStockToAllocate -= allocatedQuantity;
totalBackordersAllocated += allocatedQuantity;
allocations.push({
orderId: order.orderId,
orderName: order.orderName,
fulfillmentOrderId: order.fulfillmentOrderId,
sku: order.sku,
quantityAllocated: allocatedQuantity,
remainingBackordered,
requiresHoldRelease: order.isHeld && allocatedQuantity > 0,
});
if (order.isHeld && allocatedQuantity > 0) {
holdReleaseMutations.push({
fulfillmentOrderId: order.fulfillmentOrderId,
action: "fulfillmentOrderReleaseHold",
});
}
}
// 6. Calculate Net Storefront Surplus
// Expose ONLY units that were not claimed by backorders.
const netStorefrontSurplusInventory = remainingStockToAllocate;
return {
status: "PROCESSED",
receiptId: inboundItem.receiptId,
sku: inboundItem.sku,
quantityReceived: inboundItem.quantityReceived,
totalBackordersAllocated,
netStorefrontSurplusInventory,
allocations,
holdReleaseMutations,
shopifyInventoryAdjustmentDelta: netStorefrontSurplusInventory,
};
}The net surplus formula: exposing only unallocated inventory to the storefront
The mathematical heart of the integration is the Net Storefront Surplus Formula: `Net Storefront Delta = max(0, Quantity Received - Sum(Allocated Backorders))`.
Consider an arrival of 100 rattan chairs where B2B backorders require 50 units and retail backorders require 35 units (85 units total). The engine emits hold releases for the 85 backordered units, transitioning them into Shopify's internal `committed` inventory state.
The remaining 15 units are pushed to Shopify via `inventoryAdjustQuantities` with `delta: 15`. If the incoming shipment is smaller than open backorders (e.g. 60 units received against 85 needed), the net storefront adjustment is exactly zero: the storefront remains out of stock while all 60 units go directly to waiting customers.
Platform boundaries: FulfillmentOrder state machines and idempotency keys
Building a reliable inbound receiving bridge demands strict defensive architecture across platform limits:
When a `FulfillmentOrder` is placed on hold, releasing the hold unlocks the entire line group. If an incoming shipment only partially covers a multi-line order, execute a `fulfillmentOrderSplit` to isolate available units before releasing the hold.
Exact Online and external ERP webhooks frequently retry on transient network timeouts. If your receiving endpoint does not record processed `receiptId` values in an idempotent ledger table, duplicate events will double-allocate inventory and corrupt warehouse counts.
Batching hundreds of backorder hold releases following a container arrival can rapidly exhaust Shopify Admin API rate limits (100 points/sec standard, 1,000 points/sec on Plus). Queue hold releases through an asynchronous worker with exponential backoff.
When this needs an engineer
You do not need a custom inbound receiving engine if you sell non-backordered products where items are marked out-of-stock the moment inventory reaches zero, or if all order fulfillment is handled by a third-party logistics provider (3PL) that manages its own FIFO backorder release rules.
You need an engineer when you sell pre-orders or backorders on Shopify: when shipping container receipts must unlock waiting customer orders without manual triage; when B2B wholesale trade orders must be prioritized ahead of retail consumers; when ERP goods receipts must drive automated `fulfillmentOrderReleaseHold` mutations; and when you must eliminate storefront overselling races between new web buyers and loyal customers who paid weeks in advance.
Review our technical architecture in ERP and CRM integrations, explore the ERP integration case study collection, or examine the pre-order workflow in the Weldaad Exact Online case study.
Send us your ERP receipt data structure, warehouse fulfillment workflows, and backorder rules. We will design an automated inbound receiving engine that protects customer trust, releases pick lists instantly, and eliminates inventory races across your stores.
