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