Inventory Sync Between Shopify and Your ERP: Why It Breaks and How to Design It Right
Every mid-market Shopify store eventually oversells or ghost-stocks its way into a support crisis. Here's how to design an inventory sync that survives Black Friday, backorders, and a flaky ERP.

Every mid-market Shopify store hits the same wall around the time revenue crosses a few million a year: inventory numbers stop matching reality. Customers buy things that aren't in the warehouse, the warehouse ships things the site says are out of stock, and the merchandising team stops trusting any dashboard. Nine times out of ten, the culprit isn't Shopify or the ERP — it's the sync layer between them, quietly designed by whoever integrated them first.
This piece is a war-story-flavoured guide to designing that layer properly. It assumes you're running Shopify (or Shopify Plus) against a real ERP — NetSuite, Business Central, SAP B1, Odoo, something in that family — and you've outgrown the off-the-shelf connector.
Why the Naive Sync Always Fails
The first version of a Shopify–ERP sync usually looks like this: a cron job runs every 15 minutes, pulls stock levels from the ERP, and pushes them to Shopify via the Inventory API. It works fine in staging. It works fine for six months in production. Then one of the following happens:
- A flash sale drains a SKU faster than the 15-minute window, and you oversell 40 units.
- The ERP is down for maintenance, the job fails silently, and stock levels freeze for a day.
- A warehouse operator does a stock adjustment mid-sync, and the update gets overwritten on the next tick.
- Someone adds a second sales channel (a marketplace, a POS), and now three systems are fighting over the same source of truth.
The root cause is always the same: the sync treats inventory as a value to copy, not as a reservation system to reconcile. Real inventory has state — committed, reserved, in-transit, damaged — and Shopify's stock field is a single number. You have to design the translation carefully.
Pick a Source of Truth and Mean It
Before any code, answer one question: who owns the stock number?
In most healthy setups, the ERP (or WMS behind it) owns physical stock. Shopify owns available-to-sell, which is a derived number. The formula usually looks like:
available_to_sell = physical_on_hand
- reserved_for_open_orders
- safety_buffer
- allocations_to_other_channels
If you can't write that formula down for your business, stop and figure it out with ops before you touch the API. Half the sync bugs we've debugged came from nobody agreeing on what the number in Shopify was supposed to represent.
The safety buffer is not optional
For any SKU that sells more than a handful of units per day, keep a buffer — typically 2–5% of daily velocity or a flat 1–3 units for low-volume items. It absorbs the race conditions you can't fully eliminate and gives ops a margin to breathe. Merchandising will hate it. Do it anyway.
Event-Driven, Not Polling
Polling is fine for a catalogue of 200 SKUs. Above that, you want event-driven flows in both directions.
From ERP to Shopify: trigger on stock movement events — receipts, adjustments, transfers, shipments. Most modern ERPs can emit these via webhooks, message queues, or database change streams. If yours can't, at least poll the changed since endpoint instead of full snapshots.
From Shopify to ERP: subscribe to orders/create, orders/updated, orders/cancelled, and refunds/create. Every order is a reservation event. Every cancellation or refund releases that reservation.
The middleware sitting between them should be a proper service, not a Zapier chain. In our experience, once you're past a few thousand orders a month, no-code glue turns into a debugging nightmare.
A rough shape of the middleware
// Simplified event handler
async function handleErpStockChange(event: ErpStockEvent) {
const { sku, locationId, physicalOnHand, timestamp } = event;
// Idempotency: skip if we've already processed a newer event
const last = await store.getLastEventTimestamp(sku, locationId);
if (last && last >= timestamp) return;
const reserved = await getOpenOrderReservations(sku, locationId);
const buffer = await getSafetyBuffer(sku);
const available = Math.max(0, physicalOnHand - reserved - buffer);
await shopify.inventoryLevel.set({
inventoryItemId: await resolveInventoryItem(sku),
locationId: await resolveShopifyLocation(locationId),
available,
});
await store.setLastEventTimestamp(sku, locationId, timestamp);
}
The key details: event timestamp gating for idempotency, explicit reservation subtraction, and a Math.max(0, ...) because negative stock in Shopify does weird things to storefront filters.
Idempotency and Ordering Are Where It Bleeds
Webhooks get redelivered. Queues occasionally reorder. If your handler isn't idempotent, you will oversell.
Two patterns that work:
- Timestamp gating (as above): store the last-processed event time per SKU+location, ignore anything older.
- Version/ETag comparison: if the ERP exposes a version number on the stock record, compare before writing.
For Shopify webhooks, always dedupe on X-Shopify-Webhook-Id. Store the ID for at least 48 hours. Shopify occasionally redelivers events days later; if you process an old orders/create twice, you double-reserve stock and phantom-oversell.
Handling out-of-order deliveries
Queue-based systems (SQS, Pub/Sub, Kafka without strict partitioning) can deliver events out of order. Partition by SKU so events for the same SKU always land on the same consumer, and you can serialise per-SKU processing without a global lock. This one change has saved more inventory integrations than any other.
Multi-Location and Multi-Channel
Shopify supports multiple locations natively, and the Inventory API operates per-location. If your ERP tracks stock per warehouse but you're syncing to a single Shopify location, you're throwing away information — and probably overselling because you're summing stock that isn't all shippable to every customer.
Map ERP warehouses to Shopify locations one-to-one where possible. Then let Shopify's routing rules or a fulfillment app decide where each order ships from. If you're going headless, this logic often moves into your order orchestration layer instead — our commerce engineering team has written more of these than we'd like to admit.
For multi-channel (Shopify + Amazon + retail POS), the ERP has to allocate stock across channels before it hits Shopify. Don't try to do allocation in the sync middleware — it'll turn into a distributed transaction problem you don't want.
Observability: You Can't Fix What You Can't See
Inventory sync failures are silent by default. Nobody notices until a customer complains. Build in monitoring from day one:
- Drift detection: every night, snapshot ERP stock vs Shopify stock for every SKU. Alert on any delta above threshold. This is your smoke alarm.
- Sync lag metric: time between ERP event emission and Shopify write confirmation. Alert if p95 exceeds a minute.
- Reservation ledger: every open Shopify order should map to a reservation row you can query. If Shopify says 10 units reserved and your ledger says 7, something dropped an event.
- Rate limit headroom: Shopify's Inventory API has real limits, especially on Plus with GraphQL cost budgets. Log remaining budget and alert before you hit the ceiling.
A nightly reconciliation job that auto-corrects drift below a certain threshold (and pages a human above it) is one of the highest-ROI things you can build.
Where This Fits with Headless
If you're on Hydrogen, Next.js commerce, or any headless setup, none of this changes — Shopify is still the inventory system of record for the storefront. What does change is caching. A headless PDP that caches stock for 60 seconds can happily sell you the last unit twice. Either cache stock separately with a very short TTL (5–15 seconds), fetch it fresh on add-to-cart, or accept the oversell risk and cover it with the buffer.
The cleanest pattern we've shipped: cache the product page hard, but hit a lightweight /api/stock/:sku endpoint on add-to-cart and on checkout entry. It costs one extra request at the moment the customer actually cares about accuracy.
Where We'd Start
If you're inheriting a broken sync, don't rewrite it on day one. Instrument first: add drift detection, log every write, and figure out where the delta is actually coming from. Nine times out of ten it's one of three things — non-idempotent handlers, missing reservation logic, or a warehouse process that bypasses the ERP entirely.
Fix those, add a safety buffer, and only then start talking about event-driven rewrites. The best inventory sync is the boring one nobody has to think about — and getting there is mostly discipline, not cleverness.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Product Page LCP on Shopify: Getting Under 2.0s Without Ripping Out Your Theme
A tactical breakdown of how to cut Largest Contentful Paint on Shopify product pages to under 2.0s — without going headless, rebuilding your theme, or fighting every app you have installed.
WhatsApp Checkout in LATAM and MENA: What Actually Converts
Conversational commerce isn't a novelty in Brazil, Mexico, Egypt or the UAE — it's the default. Here's what we've learned wiring WhatsApp into real Shopify and headless stacks, and where the funnel quietly breaks.
Headless Shopify with Hydrogen: When It Pays Off and When It Bites
Hydrogen and Oxygen make headless Shopify look like a default choice in 2026. It isn't. Here's an honest breakdown of when going headless earns its keep — and when it quietly eats your roadmap.
