Futureman Labs
Platform Integration

Shopify Multi-Location Inventory Sync: Eliminate Overselling for Good

Automate inventory sync across all Shopify locations with real-time reconciliation and oversell prevention. Free n8n workflow templates included.

David YuMarch 23, 202616 min read

Picture this: you're running a DTC brand with a warehouse on the East Coast, a 3PL on the West Coast, and a retail showroom in Austin. A customer orders your best-selling product. All three locations show stock. Shopify picks a fulfillment location, decrements inventory there -- and then nothing happens at the other two locations for the next 15 minutes because your sync runs on a schedule.

During those 15 minutes, two more customers order the same SKU. One of those orders pulls from phantom inventory that doesn't physically exist at the assigned location. You've just oversold, and the customer won't find out until they get a cancellation email three days later.

This scenario plays out constantly in multi-location Shopify stores. The more locations you add, the more fragile your inventory data becomes. Here's how to diagnose the root causes and build an automation system that keeps every location accurate in real time.

How Shopify Handles Multi-Location Inventory

Before building automation, you need to understand the data model you're working with. Shopify's inventory system has three key objects:

  • Inventory Items — each product variant has exactly one inventory item. This is the SKU-level entity that tracks quantities.
  • Locations — warehouses, retail stores, 3PLs, and pop-up shops. Each location is a place where you stock and/or fulfill orders.
  • Inventory Levels — the join between inventory items and locations. Each inventory level tracks quantities for one SKU at one location.

When a customer places an order, Shopify's fulfillment engine selects a location based on priority rules you configure: proximity to the customer, stock availability, and fulfillment priority ranking. The selected location's inventory level gets decremented.

Here's the critical detail: Shopify only decrements inventory at the fulfilling location. The other locations' quantities stay untouched -- which is correct behavior. But if your external systems (ERP, WMS, or 3PL portals) aren't aware of which location was selected, they can push stale data back into Shopify and re-inflate quantities that should have gone down.

This is how most multi-location overselling actually happens. It's not that Shopify miscounts. It's that an external system overwrites the correct count with an outdated one.

The Five Sync Failures That Cause Overselling

If you're running multiple locations, you'll typically find one or more of these failure modes lurking in your stack.

Failure 1: Scheduled Sync Gaps

Most inventory integrations -- whether built-in Shopify apps or custom middleware -- sync on a schedule. Every 5 minutes, every 15 minutes, sometimes every hour. Between sync intervals, your inventory data is stale.

For a store processing 200 orders per day, a 15-minute sync interval means roughly 2 orders can land between updates. During a promotion doing 5x normal volume, that's 10 orders in a single gap. If multiple orders target the same SKU at the same location, the second order is working with outdated availability data.

Failure 2: Location-Unaware Integrations

Many integration apps were built before Shopify added multi-location support. They sync a single "total available" number without understanding which location it belongs to. When they push inventory updates, they either update only the default location or -- worse -- spread the total across all locations based on a ratio that hasn't been recalculated since setup.

You can spot this by comparing your integration's sync logs against Shopify's inventory level data per location. If the integration is pushing the same quantity to every location, or only updating one location while ignoring the others, you've found the problem.

Failure 3: Webhook Delivery Failures

Shopify fires inventory_levels/update webhooks whenever a quantity changes. Your external systems may rely on these webhooks as their primary trigger for staying in sync. But Shopify webhooks are best-effort delivery, not guaranteed. If your receiving endpoint is slow (response time over 5 seconds), down, or returning errors, Shopify retries a few times and then gives up.

During high-traffic events, webhook delivery reliability drops noticeably. A flash sale that generates 500 orders in an hour can produce thousands of inventory-level webhook events. If your endpoint can't keep up, events get lost, and your external systems fall out of sync silently.

Failure 4: Race Conditions on Concurrent Updates

When two systems try to update the same inventory level simultaneously, one update overwrites the other. This is especially common when you have both a webhook-driven sync and a scheduled polling sync running in parallel.

Here's how it plays out: your webhook handler receives an inventory change event and begins processing. Meanwhile, your scheduled sync fires and reads the current inventory level. The webhook handler finishes and writes the corrected quantity. Then the scheduled sync finishes and overwrites it with the stale quantity it read moments earlier.

Without compare-and-set semantics (more on this below), every concurrent update is a potential data loss event.

Failure 5: Transfer and Adjustment Blind Spots

Inventory transfers between locations, cycle count adjustments, and damage write-offs all change quantities at specific locations. If your automation only watches for order-related inventory changes, it misses these operational adjustments entirely.

A warehouse team does a cycle count and adjusts 15 SKUs in the WMS. Those adjustments need to propagate to Shopify's inventory levels for that specific location. If they don't, the WMS and Shopify drift apart, and the next scheduled sync either ignores the discrepancy or overwrites the WMS's corrected counts with Shopify's stale ones.

The Architecture: Real-Time Multi-Location Sync

Fixing this requires a layered approach: real-time event processing, periodic reconciliation, and oversell prevention guardrails. Here's the complete architecture.

Layer 1: Webhook-Driven Real-Time Sync

The foundation is a webhook listener that processes inventory events as they happen. Set up Shopify webhooks for three topics:

  • inventory_levels/update — fires when any quantity changes at any location
  • orders/create — fires when a new order is placed
  • fulfillments/create — fires when an order is fulfilled at a specific location

In n8n, create a Webhook node that receives these events and routes them through a processing pipeline:

Shopify Webhook (inventory_levels/update)
  -> Validate HMAC signature
  -> Extract inventory_item_id + location_id + available quantity
  -> Look up corresponding SKU in your ERP/WMS
  -> Compare Shopify quantity vs. ERP/WMS quantity
  -> Branch:
    -> Match: log and continue
    -> Mismatch: determine source of truth, apply correction
  -> Log event to audit database

The key decision in this pipeline is which system is the source of truth for each location. For a warehouse you operate yourself, the WMS is the source of truth. For a 3PL, the 3PL's system is the source of truth. For a retail store using Shopify POS, Shopify is the source of truth.

Map this out explicitly. A common pattern:

Location TypeSource of TruthSync Direction
Owned warehouseWMSWMS -> Shopify
3PL facility3PL system3PL -> Shopify
Retail store (POS)ShopifyShopify -> ERP
Pop-up / eventShopifyShopify -> ERP

Layer 2: The Reconciliation Engine

Webhooks handle real-time events, but they miss things. The reconciliation engine runs on a schedule to catch drift that webhooks missed.

Build an n8n workflow that runs every 10 minutes during business hours and every 30 minutes overnight:

// Step 1: Fetch all inventory levels from Shopify (paginated)
const shopifyInventory = await fetchAllInventoryLevels();

// Step 2: Fetch current quantities from each source-of-truth system
const wmsInventory = await fetchWMSInventory();
const threePLInventory = await fetch3PLInventory();

// Step 3: Build a unified comparison map
const discrepancies = [];

for (const item of shopifyInventory) {
  const sourceOfTruth = getSourceOfTruth(item.location_id);
  let expectedQuantity;

  if (sourceOfTruth === 'wms') {
    expectedQuantity = wmsInventory[item.sku]?.[item.location_id] ?? 0;
  } else if (sourceOfTruth === '3pl') {
    expectedQuantity = threePLInventory[item.sku]?.[item.location_id] ?? 0;
  } else {
    continue; // Shopify is source of truth, skip
  }

  if (item.available !== expectedQuantity) {
    discrepancies.push({
      sku: item.sku,
      location_id: item.location_id,
      shopify_qty: item.available,
      expected_qty: expectedQuantity,
      delta: expectedQuantity - item.available,
      source: sourceOfTruth
    });
  }
}

return discrepancies;

For each discrepancy, apply auto-correction rules:

  • Delta of 1-5 units: Auto-correct in Shopify. Log the correction.
  • Delta of 6-20 units: Auto-correct, but send a Slack alert for review.
  • Delta of 20+ units: Do NOT auto-correct. Send an urgent alert. This likely indicates a bulk operation (transfer, receiving) that hasn't completed propagating yet.

Layer 3: The Shopify GraphQL API for Corrections

When correcting inventory in Shopify, use the GraphQL Admin API -- the REST inventory endpoints are legacy as of 2025. There are two mutations to know:

inventoryAdjustQuantities — for incremental changes (add or subtract a delta). Use this when you know the change that occurred but aren't sure of the absolute quantity:

mutation inventoryAdjustQuantities($input: InventoryAdjustQuantitiesInput!) {
  inventoryAdjustQuantities(input: $input) {
    userErrors {
      field
      message
    }
    inventoryAdjustmentGroup {
      reason
      changes {
        name
        delta
        quantityAfterChange
      }
    }
  }
}

With variables:

{
  "input": {
    "reason": "correction",
    "name": "available",
    "changes": [
      {
        "delta": -5,
        "inventoryItemId": "gid://shopify/InventoryItem/12345",
        "locationId": "gid://shopify/Location/67890"
      }
    ]
  }
}

inventorySetQuantities — for setting absolute values. This supports compare-and-set to prevent race conditions:

mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    userErrors {
      field
      message
    }
    inventoryAdjustmentGroup {
      reason
      changes {
        name
        delta
        quantityAfterChange
      }
    }
  }
}

With variables:

{
  "input": {
    "reason": "correction",
    "name": "available",
    "ignoreCompareQuantity": false,
    "quantities": [
      {
        "inventoryItemId": "gid://shopify/InventoryItem/12345",
        "locationId": "gid://shopify/Location/67890",
        "quantity": 42,
        "compareQuantity": 47
      }
    ]
  }
}

The compareQuantity field is critical. It tells Shopify: "Only apply this update if the current quantity is 47. If someone else changed it in the meantime, reject the update." This eliminates the race condition described in Failure 4 above.

Always use ignoreCompareQuantity: false in your reconciliation engine. If the compare fails, re-fetch the current quantity and recalculate before retrying. This adds a round-trip but prevents your reconciliation from overwriting a legitimate change that happened between your read and write.

Not Sure Where to Start?

Take our free Growth Bottleneck Audit. We'll identify the #1 constraint choking your growth and show you exactly how to fix it.

Building the n8n Workflow: Step by Step

Here's how to wire this together in n8n as a practical, deployable workflow.

Step 1: Set Up the Webhook Listener

Create a new workflow in n8n with a Webhook node as the trigger. Configure it to accept POST requests at a path like /shopify-inventory-sync. In your Shopify admin, register webhooks for inventory_levels/update pointing to your n8n webhook URL.

Add an HMAC validation Function node immediately after the webhook:

const crypto = require('crypto');
const secret = $env.SHOPIFY_WEBHOOK_SECRET;
const hmac = crypto
  .createHmac('sha256', secret)
  .update(JSON.stringify($input.body))
  .digest('base64');

if (hmac !== $input.headers['x-shopify-hmac-sha256']) {
  throw new Error('Invalid webhook signature - rejecting');
}

return {
  inventory_item_id: $input.body.inventory_item_id,
  location_id: $input.body.location_id,
  available: $input.body.available,
  updated_at: $input.body.updated_at
};

Step 2: Build the Comparison Logic

After validation, add an HTTP Request node that fetches the corresponding quantity from your source-of-truth system (WMS or 3PL). Then add a Function node that compares:

const shopifyQty = $input.item.json.available;
const sourceQty = $input.item.json.wms_available;
const locationId = $input.item.json.location_id;
const sku = $input.item.json.sku;

if (shopifyQty === sourceQty) {
  return { status: 'in_sync', sku, locationId, quantity: shopifyQty };
}

const delta = sourceQty - shopifyQty;
const severity = Math.abs(delta) > 20 ? 'critical'
  : Math.abs(delta) > 5 ? 'warning'
  : 'auto_correct';

return {
  status: 'discrepancy',
  sku,
  locationId,
  shopify_qty: shopifyQty,
  source_qty: sourceQty,
  delta,
  severity
};

Step 3: Route by Severity

Add a Switch node that routes based on the severity field:

  • auto_correct path: goes directly to a Shopify GraphQL node that runs inventorySetQuantities with compare-and-set
  • warning path: corrects the quantity AND sends a Slack message to your ops channel
  • critical path: sends a Slack alert with full context and does NOT auto-correct. Requires manual review.

Step 4: Add the Scheduled Reconciliation

Create a second workflow triggered by a Cron node (every 10 minutes). This workflow:

  1. Fetches all active inventory items from Shopify using paginated GraphQL queries
  2. Fetches corresponding quantities from your WMS/3PL
  3. Compares every SKU at every location
  4. Feeds discrepancies into the same severity-based routing as the webhook workflow

This is your safety net. Even if every webhook fails for an hour, the reconciliation will catch and correct drift within 10 minutes.

Step 5: Build the Monitoring Dashboard

Add a final step to both workflows that logs every event to a Google Sheet or Airtable base:

TimestampSKULocationShopify QtySource QtyDeltaAction TakenStatus
2026-03-23 14:02SKU-1234Warehouse East4542-3Auto-correctedSuccess
2026-03-23 14:05SKU-56783PL West1238+26Alert sentPending review

This log gives your ops team visibility into exactly what the automation is doing and helps identify recurring patterns -- like a specific location or integration that consistently drifts.

Oversell Prevention: The Safety Layer

Sync accuracy is one side of the problem. The other side is preventing oversells even when quantities are temporarily wrong. Here's how to add a safety buffer.

Safety Stock Thresholds

Instead of selling down to zero at each location, configure a minimum safety stock that your automation enforces:

// After setting inventory in Shopify, check if we're below safety stock
const safetyStock = getSafetyStock(sku, locationId); // e.g., 3 units
const currentAvailable = newQuantity;

if (currentAvailable > 0 && currentAvailable <= safetyStock) {
  // Send low-stock alert
  await sendSlackAlert({
    channel: '#inventory-alerts',
    text: `Low stock warning: ${sku} at ${locationName} has ${currentAvailable} units (safety stock: ${safetyStock})`
  });
}

if (currentAvailable <= 0) {
  // Verify with source of truth before allowing zero
  const verifiedQty = await verifyWithSourceOfTruth(sku, locationId);
  if (verifiedQty > 0) {
    // Source of truth says we have stock - correct Shopify
    await correctShopifyInventory(sku, locationId, verifiedQty);
  }
}

Cross-Location Available-to-Promise

For high-velocity SKUs, implement an available-to-promise (ATP) calculation that considers all locations:

function calculateATP(sku, allLocations) {
  let totalAvailable = 0;
  let totalReserved = 0;

  for (const location of allLocations) {
    const level = getInventoryLevel(sku, location.id);
    const reserved = getPendingFulfillments(sku, location.id);

    totalAvailable += level.available;
    totalReserved += reserved;
  }

  // ATP = total physical stock minus all commitments
  const atp = totalAvailable - totalReserved;

  // If ATP is below threshold, trigger rebalancing check
  if (atp <= getReorderPoint(sku)) {
    triggerReorderAlert(sku, atp);
  }

  return atp;
}

This gives you a network-wide view of availability rather than location-by-location. If Location A is out of stock but Location B has 50 units, the customer can still order -- you just need to route fulfillment to Location B.

Handling Inventory Transfers Between Locations

Stock transfers are a major source of sync errors. When you move 100 units from Warehouse A to Warehouse B, there's a transit period where the inventory exists physically in neither location. If your automation doesn't account for this, you'll either double-count (units still showing at origin) or under-count (units removed from origin but not yet added to destination).

Shopify doesn't have a native transfer API. You have to manage this with a two-step adjustment:

// Step 1: Decrement at origin location
await shopifyGraphQL(`
  mutation {
    inventoryAdjustQuantities(input: {
      reason: "movement_updated",
      name: "available",
      changes: [{
        delta: -100,
        inventoryItemId: "gid://shopify/InventoryItem/12345",
        locationId: "gid://shopify/Location/ORIGIN_ID"
      }]
    }) {
      userErrors { field message }
    }
  }
`);

// Step 2: Create a "transit" record in your system
await createTransitRecord({
  sku: 'SKU-1234',
  quantity: 100,
  origin: 'ORIGIN_ID',
  destination: 'DEST_ID',
  expected_arrival: '2026-03-25',
  status: 'in_transit'
});

// Step 3: When goods are received at destination, increment
// This is triggered by a webhook from your WMS or manual confirmation
await shopifyGraphQL(`
  mutation {
    inventoryAdjustQuantities(input: {
      reason: "movement_updated",
      name: "available",
      changes: [{
        delta: 100,
        inventoryItemId: "gid://shopify/InventoryItem/12345",
        locationId: "gid://shopify/Location/DEST_ID"
      }]
    }) {
      userErrors { field message }
    }
  }
`);

Your reconciliation engine needs to be aware of in-transit inventory. When it detects a discrepancy at the origin location, it should check for active transfer records before flagging it as an error.

Common Mistakes to Avoid

Mistake 1: Syncing total inventory instead of per-location. If your integration pushes a single aggregated number to Shopify without location granularity, you're guaranteed to have phantom inventory at some locations. Every sync operation must specify both the inventory_item_id and the location_id.

Mistake 2: Using the REST API for inventory operations. Shopify's REST Admin API is a legacy API. The GraphQL mutations (inventoryAdjustQuantities, inventorySetQuantities) offer better concurrency control, batch support, and error handling. Migrate to GraphQL if you haven't already.

Mistake 3: Not using compare-and-set. Setting ignoreCompareQuantity: true on inventorySetQuantities is tempting because it simplifies your code. But it means every write is a blind overwrite. In a multi-location setup with multiple systems writing inventory data, blind overwrites cause cascading errors.

Mistake 4: Running reconciliation without rate-limit awareness. If you have 5,000 SKUs across 4 locations, a full reconciliation touches 20,000 inventory levels. Shopify's GraphQL API has a point-based rate limit (typically 1,000 points per second for the Admin API). Batch your queries using bulk operations or paginate with delays. In n8n, use the Split In Batches node with a 500ms pause between batches.

Mistake 5: Ignoring the "incoming" and "committed" quantities. Shopify tracks more than just "available." Each inventory level also has "incoming" (purchase orders en route), "committed" (allocated to unfulfilled orders), "reserved," and "damaged" quantities. If your sync only watches "available" and ignores the others, you're missing context that explains why the numbers look wrong. Incoming inventory that hasn't been received yet isn't available to sell -- but it tells you a restock is coming.

Mistake 6: No audit trail. When something goes wrong -- and it will -- you need to reconstruct exactly what happened. Log every inventory change your automation makes: the before value, the after value, which system triggered it, and whether it was auto-corrected or manually reviewed. Without this, debugging sync issues becomes guesswork.

What to Expect After Implementation

A well-built multi-location inventory automation system delivers measurable results:

  • Inventory accuracy jumps from 80-85% to 99%+. Most stores don't realize how far off their numbers are until they start measuring.
  • Oversell incidents drop to near zero. The combination of real-time sync, scheduled reconciliation, and safety stock buffers catches discrepancies before customers are affected.
  • Ops team time savings of 10-20 hours per week. Manual inventory checks, spreadsheet reconciliations, and location-by-location audits disappear.
  • 3PL and WMS integration issues surface immediately. Instead of discovering a sync failure three days later when a customer complains, you get a Slack alert within minutes.

The infrastructure cost is modest: an n8n instance (self-hosted for around $20-50/month or n8n Cloud), plus the monitoring and logging tools you're likely already using. Against the cost of even one oversell incident during a sale event, the ROI is immediate.

If you're operating more than two Shopify locations and you don't have automated reconciliation running, you have inventory drift right now. The only question is how much, and whether you'll catch it before your customers do.

Want to know exactly where your operations are leaking money? Take the free Growth Bottleneck Audit — it takes 2 minutes and shows you your biggest automation opportunities.

Not Sure Where to Start?

Take our free Growth Bottleneck Audit. We'll identify the #1 constraint choking your growth and show you exactly how to fix it.

Want to Talk Through Your Automation Needs?

Book a 30-minute call. We'll map out which automations would save you the most time — no obligation.