Real-time inventory and order sync between Shopify and an ERP
Scenario: multi-channel retailer with a warehouse ERP
A complete worked design — the architecture we deploy for the most common integration failure in commerce: a store and a warehouse that disagree.
The problem
Retailers running Shopify alongside an ERP typically reconcile the two by hand: stock exports in the morning, order re-entry in the afternoon. Overselling happens whenever the store lags the warehouse; hours disappear into retyping; and nobody trusts either system's numbers. Off-the-shelf connectors cover the happy path but fall over on the details — partial fulfillments, bundles, refunds — and fail silently when an API hiccups.
Requirements
- Stock changes in the ERP visible on the storefront within seconds, not on tomorrow's import
- Orders flowing into the ERP exactly once — no duplicates on retry, no losses on outage
- Partial fulfillments, refunds, and cancellations handled, not just clean orders
- A status view a non-developer can read: what synced, what failed, what was retried
- Survive either system being down for an hour without data loss
The solution
An event-driven sync service that treats the ERP as the source of truth for stock and Shopify as the source of truth for orders. Shopify webhooks and ERP change events land in a queue; workers apply them with idempotency keys and retries with backoff; a small dashboard shows every event's journey. Nothing is 'synced by schedule' — the schedule is only a reconciliation sweep that catches anything the events missed.
Architecture
Shopify webhooks (orders, refunds, fulfillments) and ERP stock events are received by verified webhook endpoints and written to a queue before any processing — receipt and processing are deliberately separated. Idempotent workers consume the queue: order events become ERP documents, stock events become Shopify inventory-level updates via the Admin GraphQL API. A dead-letter queue plus a nightly reconciliation job (full stock comparison, order-count cross-check) catches drift. State and audit history live in Postgres.
Challenges & resolutions
Shopify webhooks are at-least-once: the same order event can arrive twice, and a naive handler creates two ERP documents.
Resolution — Every event carries an idempotency key derived from Shopify's event ID; workers record processed keys in Postgres and treat repeats as no-ops. Retries are safe by construction rather than by luck.
Bulk stock updates from the ERP (a delivery arriving) produce hundreds of near-simultaneous inventory calls — enough to trip Shopify's rate limits.
Resolution — Stock events are debounced per SKU and flushed through a rate-aware batcher that respects Shopify's cost-based GraphQL limits, so a delivery updates the store in one orderly wave instead of a thundering herd.
During an ERP outage the queue must keep accepting events — the failure mode has to be 'delayed', never 'lost' or 'duplicated'.
Resolution — Workers back off exponentially and events age in the queue rather than being dropped; the dashboard shows the backlog and an alert fires past a threshold. When the ERP returns, the backlog drains in order, with idempotency preventing double-application.
Why this design exists
"Our store and our warehouse disagree" is one of the most common integration failures in commerce. Rather than explain our approach in the abstract, this is the complete worked design we would deploy for a real retailer — documented honestly, including the parts that are harder than the architecture diagram suggests.
Design notes
Receipt is not processing. The webhook endpoints do exactly two things: verify the HMAC signature, and write the raw event to the queue. The classic failure modes of home-grown syncs all trace back to doing real work inside the webhook handler — Shopify times out, retries, and the handler double-applies. Separating receipt from processing makes the timeout problem structurally impossible.
The ERP owns stock; Shopify owns orders. Half of integration pain is two systems both believing they own a fact. Here the direction of truth is fixed per data type, which makes every flow one-directional and every conflict resolvable by rule instead of by human.
Reconciliation is the safety net, not the mechanism. A nightly job compares full stock states and order counts between systems. In steady state it finds nothing — its job is to catch the event that a bug, an outage, or a future developer's mistake causes to vanish. When it finds drift, it fixes it and files a report, because drift you know about is an incident; drift you don't is a slow-motion disaster.
The dashboard earns its keep. It's deliberately boring: a table of events, their status, and a retry button. But it changes the operational relationship — the merchant's team can answer their own "did it sync?" questions, and support conversations start from shared facts.
What we'd adapt per client
The ERP side is the variable: the design speaks a generic REST inventory API, and a real engagement swaps in the actual system (NetSuite, Odoo, Unleashed, DEAR/Cin7, or a legacy database behind a thin API). The queue-worker-reconcile skeleton, the idempotency discipline, and the observability layer carry over unchanged — which is exactly why we keep this as our reference design.
What the design guarantees
- Stock changes propagate to the storefront in seconds; overselling requires both systems to be wrong at once
- Order re-entry disappears as a category of work — the design covers every order shape Shopify can emit, including partials and refunds
- Every event is inspectable: a non-developer can answer 'did order 1052 reach the ERP?' from the dashboard
- Either system can be offline for an hour with zero data loss — events age in the queue instead of dropping, and idempotency makes the drain safe by construction
Have a similar problem?
Describe the problem in plain language — broken, slow, manual, or missing. We'll tell you honestly whether and how we can help.
- 01We reply within one business day
- 02A short call to understand the problem
- 03A written scope and fixed quote — no obligation