Skip to content
HeapByte — Senior Shopify Engineering Studio

Integration · 5 November 2026

Shopify order cancellations in ERP integrations: warehouse picking locks, post-commit race conditions, and ghost stock

A high-volume home decor and lifestyle brand runs their ecommerce storefront on Shopify Plus integrated with Exact Online ERP. On a busy Monday morning, a customer places an order for a £1,400 ceramic dining table. Within ninety seconds, the integration middleware ingests the orders/create webhook, validates stock, generates an Exact Online Sales Order, allocates physical warehouse inventory, and routes the picking ticket directly to the warehouse terminal. Thirty minutes later, the customer calls support requesting an immediate cancellation because they mismeasured their dining alcove. A support agent clicks 'Cancel Order' in Shopify Admin, and Shopify automatically refunds the credit card. But in the warehouse, pickers have already loaded the crate onto a delivery pallet. Because the integration lacked a picking lock verification gate, the ERP rejected the cancellation webhook with an unhandled status error. Forty-eight hours later, the table is delivered to the customer, who now possesses both the £1,400 furniture and a full credit card refund.

8 min read
Written by the HeapByte engineering team

Shopify order cancellation ERP

The post-commit race condition: why naive cancellation webhooks lose stock and revenue

When developers build a Shopify-to-ERP connector, the happy path is straightforward: listen for orders/paid or orders/create, map line items to ERP SKUs, and post a Sales Order via REST API.

The breakdown occurs when the order must be halted or amended. In standard ecommerce operations, between 1.5% and 4% of orders are cancelled or modified shortly after placement.

In naive architectures, order cancellation triggers one of two catastrophic failure modes: silent cancellation rejection and unrecoverable stock loss, or phantom inventory locks (ghost stockouts).

In the first case, Shopify executes an immediate credit card refund and sends an asynchronous orders/cancelled webhook. If the warehouse has already transitioned the order to picking, Exact Online rejects the deletion API call (Status = 50). The error drops into an unmonitored log, the pickers complete the packing slip, and the customer receives free merchandise.

In the second case, an unhandled ERP rejection leaves the Sales Order open in the ERP ledger. The ERP continues counting those units as committed stock, suppressing available inventory counts in downstream synchronization and starving storefront PDPs of valid stock.

Exact Online Sales Order states and warehouse picking lock mechanics

To build a resilient cancellation pipeline, the connector must understand the internal state machine of the ERP. In Exact Online, Sales Orders progress through discrete operational lifecycle statuses: Draft (Status = 12), Open (Status = 20), Processing/In-Picking (Status = 50), and Delivered/Dispatched (Status = 100 with GoodsDelivery lines).

Once a warehouse supervisor prints a pick ticket or a picker scans the order barcode into a warehouse management tool (like Mondial or Picqer), Exact Online generates internal picking allocation records.

Attempting to delete a Sales Order in Status = 50 via API will fail by design with an HTTP 400 validation error. An integration cannot simply overwrite the database state without corrupting the warehouse floor workflow.

Two-phase cancellation gating and fulfillment order hold mechanics

Instead of firing blind cancellation webhooks, a production sync architecture implements a two-phase cancellation gate paired with Shopify's native Fulfillment Orders API.

When a cancellation request originates in Shopify: Phase 1 queries real-time ERP status before committing any refund. If the order is Open (Status = 20), the ERP void succeeds atomically, releasing stock and completing the refund.

If the order is already in picking (Status = 50), Phase 2 immediately places a defensive hold on Shopify's FulfillmentOrder using the fulfillmentOrderHold mutation with reason OTHER and note 'ERP_PICKING_IN_PROGRESS: Intercept required'. This blocks courier label printing and tracking dispatch.

Phase 3 dispatches an alert to warehouse mobile terminals. Once the warehouse operator physically locates the tote, removes the merchandise, and confirms the restock, the ERP cancellation completes and Shopify releases the customer refund.

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

export type ERPSalesOrderStatus = 12 | 20 | 50 | 100; // 12: Draft/Void, 20: Open, 50: In Picking, 100: Dispatched

export interface ERPOrderRecord {
  orderId: string;
  orderNumber: number;
  status: ERPSalesOrderStatus;
  hasGoodsDelivery: boolean;
  allocatedLines: Array<{ sku: string; quantity: number }>;
}

export interface ShopifyCancellationRequest {
  shopifyOrderId: string;
  fulfillmentOrderId: string;
  refundRequestedCents: number;
  reason: string;
  initiatedAt: string;
}

export type CancellationSyncOutcome =
  | { action: "CANCELLED_AUTOMATICALLY"; releasedSkus: Record<string, number> }
  | { action: "PICKING_LOCK_HOLD_ENGAGED"; holdReason: string; notificationDispatched: boolean }
  | { action: "REJECTED_ALREADY_DISPATCHED"; rmaRequired: true };

export class OrderCancellationSyncCoordinator {
  constructor(
    private erpDatabase: Record<string, ERPOrderRecord>,
    public activePickingReservations: Record<string, number> // SKU -> reserved units
  ) {}

  public evaluateCancellation(
    request: ShopifyCancellationRequest
  ): CancellationSyncOutcome {
    const erpOrder = this.erpDatabase[request.shopifyOrderId];

    if (!erpOrder) {
      throw new Error(`ERP Sales Order record not found for Shopify Order ${request.shopifyOrderId}`);
    }

    // Case 1: Order has already been physically dispatched / manifested
    if (erpOrder.hasGoodsDelivery || erpOrder.status === 100) {
      return {
        action: "REJECTED_ALREADY_DISPATCHED",
        rmaRequired: true,
      };
    }

    // Case 2: Order is currently locked in active warehouse picking
    if (erpOrder.status === 50) {
      return {
        action: "PICKING_LOCK_HOLD_ENGAGED",
        holdReason: `ERP_PICKING_IN_PROGRESS: Order #${erpOrder.orderNumber} is on the warehouse floor. Physical intercept required.`,
        notificationDispatched: true,
      };
    }

    // Case 3: Order is open and unpicked - safe for automated atomic cancellation
    if (erpOrder.status === 20 || erpOrder.status === 12) {
      const releasedSkus: Record<string, number> = {};

      for (const line of erpOrder.allocatedLines) {
        const currentReserved = this.activePickingReservations[line.sku] || 0;
        this.activePickingReservations[line.sku] = Math.max(0, currentReserved - line.quantity);
        releasedSkus[line.sku] = line.quantity;
      }

      erpOrder.status = 12; // Revert to draft / void status

      return {
        action: "CANCELLED_AUTOMATICALLY",
        releasedSkus,
      };
    }

    throw new Error(`Unhandled ERP sales order status: ${erpOrder.status}`);
  }

  public buildFulfillmentOrderHoldPayload(
    fulfillmentOrderId: string,
    holdNotes: string
  ) {
    return {
      query: `
        mutation FulfillmentOrderHold($id: ID!, $reason: FulfillmentHoldReason!, $reasonNotes: String!) {
          fulfillmentOrderHold(id: $id, reason: $reason, reasonNotes: $reasonNotes) {
            fulfillmentOrder {
              id
              status
            }
            userErrors {
              field
              message
            }
          }
        }
      `,
      variables: {
        id: fulfillmentOrderId,
        reason: "OTHER",
        reasonNotes: holdNotes,
      },
    };
  }

  public confirmManualWarehouseIntercept(
    shopifyOrderId: string,
    confirmedByStaffId: string
  ): { restocked: boolean; releasedSkus: Record<string, number> } {
    const erpOrder = this.erpDatabase[shopifyOrderId];
    if (!erpOrder || erpOrder.status !== 50) {
      throw new Error("Cannot execute intercept confirmation on an order not in picking status");
    }

    const releasedSkus: Record<string, number> = {};
    for (const line of erpOrder.allocatedLines) {
      const currentReserved = this.activePickingReservations[line.sku] || 0;
      this.activePickingReservations[line.sku] = Math.max(0, currentReserved - line.quantity);
      releasedSkus[line.sku] = line.quantity;
    }

    erpOrder.status = 12; // Order aborted and unallocated
    return {
      restocked: true,
      releasedSkus,
    };
  }
}
The coordinator inspects the ERP's internal operational state before permitting an automated cancellation. An earlier version executed an asynchronous DELETE call immediately upon receiving the Shopify webhook. When orders had already entered picking (Status = 50), the ERP returned an unhandled error, leaving Shopify showing 'Cancelled and Refunded' while pickers dispatched the parcel. The current architecture halts automatic refunds for in-flight picking orders, engages a defensive Shopify fulfillment order hold, and alerts the warehouse floor for physical abort verification.

Reconciling partial line cancellations, restock events, and phantom allocations

In B2B wholesale and complex retail orders, cancellations are frequently partial. A trade customer ordering twenty SKUs may cancel five lines due to jobsite schedule changes while expecting the remaining fifteen items to ship on schedule.

Handling partial cancellations requires granular line-level orchestration: (1) translating Shopify Order Editing events (OrderEditCommit) into Exact Online SalesOrderLines PATCH requests without voiding the parent order, (2) preventing dual inventory restocking where both Shopify and the ERP attempt to increment stock levels, and (3) purging middleware picking allocations in Redis so phantom reservations do not persist after the ERP line is deleted.

For architecture on managing fulfillment states and allocation, review our analysis of Shopify fulfillment orders in ERP sync and warehouse picking reservations.

When this needs an engineer

You do not need a custom cancellation synchronization engine if your warehouse operations fulfill orders with a standard next-day batch run using native Shopify fulfillment locations and manual pick-pack workflows. In small stores, support staff can simply check with the packing bench before clicking cancel. Similarly, purely digital goods or gift cards do not involve physical warehouse locks.

You need custom ERP cancellation state machines and warehouse picking locks when: your warehouse fulfills orders on aggressive same-day SLAs where orders enter picking terminals within minutes of placement; your business experiences inventory discrepancies and double-refund losses where customers cancel orders in Shopify and still receive physical parcels; your ERP system locks Sales Orders during fulfillment (such as Exact Online Status 50 or SAP delivery documents); or cancelled orders leave ghost inventory reservations that suppress storefront stock.

Explore our engineering services in Shopify ERP integration, inspect our ERP integration case study collection, or review how operational availability was architected in the Weldaad Order Availability Dashboard case study.

Send us the store and the symptom. We will architect a resilient order cancellation synchronization pipeline that prevents stock loss and eliminates phantom allocations.

Insights

Apply this to your store.

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