Skip to content
HeapByte — Senior Shopify Engineering Studio

Integration · 26 October 2026

Shopify ERP inbound receiving: releasing backorders without storefront inventory races

A 40-foot shipping container docks at a lifestyle brand's central distribution warehouse in Amsterdam carrying 600 units of handcrafted rattan lounge chairs. Warehouse receiving staff scan pallet manifests, conduct quality inspections, and book a GoodsReceipt in Exact Online. Sixty seconds later, the standard off-the-shelf ERP connector detects the stock movement and executes a brute-force Shopify inventory mutation: inventoryAdjustQuantities setting available inventory to 600 units. An automated marketing campaign fires, and evening storefront shoppers eagerly snap up 250 chairs. Meanwhile, 180 loyal customers who purchased those exact chairs on backorder four weeks ago sit with open orders stuck in pending queues. Because the new orders arrived after stock showed as 'available', the warehouse management system immediately routes pick tickets for the new shoppers, while the backordered customers are left stranded with unfulfilled orders when physical pallets run dry.

6 min read
Written by the HeapByte engineering team

Shopify ERP inbound recieving backorder release

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.

ts
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,
  };
}
Why this function adjusts Shopify inventory by net surplus rather than gross received quantity: when an ERP connector blindly sets Shopify available inventory to the gross received quantity, the storefront immediately exposes backordered units for sale to new visitors. If 100 units arrive and 85 are owed to backorders, publishing +100 units allows 100 new buyers to check out, while 85 backorders also expect fulfillment: creating an immediate 85-unit physical oversell. Our engine allocates 85 units to waiting orders, unlocks their fulfillment holds, and transmits an inventory adjustment delta of only +15 units, keeping physical warehouse bins and digital storefronts in perfect balance.

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.

Insights

Apply this to your store.

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