Prevent Inventory Oversell on Multiple Shopify Stores
How I stopped overselling the same SKU across multiple Shopify stores — the webhook gap behind it, and the idempotent sync + reconciliation that fixed it.
Key points — AI summary
- Oversell across stores is rarely a "wrong number" problem — it's a sync pipeline that quietly drifts because it double-counts, misses, or reorders inventory events
- Shopify states plainly that webhook delivery isn't guaranteed and recommends deduping on X-Shopify-Webhook-Id plus periodic reconciliation jobs — build for that, not for a perfect stream
- Make your sync handler idempotent first: store processed webhook IDs and skip repeats, so replaying the same event twice can't decrement stock twice
- A nightly reconciliation job that re-reads Admin API counts and corrects drift is the safety net webhooks can't be — treat webhooks as the fast path, the API as source of truth
- Alert on divergence between stores before it becomes an oversold order; catching drift early is cheaper than cancelling a customer's order after the fact
Summarized from this article by our writing pipeline; reviewed by the author.
On this page
In April 2026 one of the stores we run oversold a product that physically existed exactly once. The same SKU was live on three storefronts sharing a single stock pool, a customer bought the last unit on Store A, and eleven minutes later a second customer bought "the last unit" again on Store C. Two orders, one item, one refund, and one email that started with "so where is my package actually." That refund cost us maybe fifteen dollars. The lost trust cost more, and I couldn't tell you the number, which is exactly why it bothered me. One refund is a rounding error; the pattern isn't — IHL Group estimates retailers lose over $1.7 trillion a year worldwide to out-of-stocks and overstocks, and a stock pool that quietly drifts is how you join that number one oversold order at a time.
I'd built the inventory sync layer myself, so I couldn't blame an app. This post is what I found when I opened it up: why overselling across multiple Shopify stores is almost never a "the number was wrong" bug, and almost always a "the pipeline that maintains the number drifted" bug. If you want the strategy-level view of keeping stock aligned across storefronts, I wrote that separately in inventory sync across multiple Shopify stores. This one is the engineering post-mortem.
The number was right until it wasn't
My first assumption was that some store had a stale count from the start. It didn't. I exported inventory from all three stores the morning of the incident and every count matched. The drift happened during the eleven minutes, under load, which is the worst possible time to debug anything.
Here's the shape of what I'd built. Each store fired orders/create and inventory_levels/update webhooks at my Node.js endpoint. The handler read the event, computed the new pool quantity, and pushed the corrected number back to the other two stores over the Admin API. It worked perfectly in testing. It worked perfectly for months. It failed specifically when two orders landed close together, because my handler treated every webhook as a fresh, unique, exactly-once truth.
It is none of those things. That was the whole bug, and I'd designed it in.
What Shopify actually promises about webhook delivery
I went back to the primary source instead of my assumptions, and I'd recommend anyone building this do the same. Shopify's own webhook best-practices documentation states it flatly: "Webhook delivery isn't always guaranteed, and your app can miss or mishandle events for other reasons, such as handler failures or downtime."
Read that sentence the way an operator has to, not the way a demo does. It means two things can happen to your inventory pipeline, and both did to mine:
- You can receive the same event more than once. Shopify's guidance is to "ignore duplicate deliveries using
X-Shopify-Webhook-Id" — a deduplication header exists precisely because duplicates are expected, not exceptional. - *You can silently not receive an event at all.* Delivery isn't guaranteed. A handler timeout, a deploy at the wrong second, a downstream hiccup — the event is gone and Shopify won't apologize.
My handler had no defense against either. When two orders/create events for the shared pool arrived nearly simultaneously, both read the same "1 in stock," both decided the new pool value, and both wrote it. Neither saw the other. Classic read-modify-write race, dressed up as an inventory problem. If you're fuzzy on how these events are delivered and retried in the first place, the mechanics are worth understanding cold — I laid them out in Shopify webhooks basics, because you can't build reliable sync on a delivery model you're guessing about.
The fix that didn't work: just add a lock
My first patch was the obvious one — wrap the read-modify-write in a lock so only one webhook mutates the pool at a time. That killed the race. It did nothing for the second failure mode, and I found that out the hard way about a week later when a deploy ate an orders/create webhook entirely. No duplicate that time. A missing event. Stock stayed one unit too high on two stores for most of a day until a customer found the gap for me again.
A lock makes concurrent processing safe. It does not make processing correct when the input stream itself is lossy and occasionally repeats. I'd solved the smaller half of the problem and declared victory, which is a mistake I keep having to relearn.
What actually fixed it: idempotency first, reconciliation second
The real fix had two layers, and the order matters.
Layer one — make every handler idempotent. Before doing anything with a webhook, I now record its X-Shopify-Webhook-Id in a processed-events table and check that table on the way in. Seen this ID already? Acknowledge with a 200 and do nothing else. This is the single highest-leverage change I made. Idempotency means "processing the same event twice has the same result as processing it once," and once you have it, a duplicate delivery is a non-event instead of a double-decrement. It also lets you retry aggressively without fear, because a replay is free. Shopify hands you the dedup key; the mistake is not using it.
Layer two — reconcile on a schedule, because webhooks will still miss. Idempotency fixes duplicates. It does nothing for the events that never arrive. For those, Shopify's own recommendation is the answer: "use reconciliation jobs to periodically fetch data from Shopify so that your app stays consistent with Shopify's data." So now a nightly job re-reads the actual inventory count from each store's Admin API, compares it to what my source-of-truth database believes, and corrects any divergence. Webhooks are the fast path — they keep the pool live-ish within seconds. The reconciliation job is the truth — it guarantees that by tomorrow morning, no matter what the stream dropped, the numbers converge.
I'll say the opinion plainly, because it's the thing I'd pin to a whiteboard: treat webhooks as a cache-warming optimization, never as your ledger. Any inventory system that trusts the webhook stream as the source of truth is one dropped event away from an oversold order. Mine was, for months, and it looked fine the entire time.
Catching drift before a customer does
The last piece was refusing to learn about drift from an angry email. The reconciliation job already computes divergence between what each store reports and what the pool believes — so I made it alert on that divergence instead of just silently correcting it. If Store A says 4 and the pool says 6, something upstream is dropping or double-firing events, and I want to know while it's a two-unit gap, not after it becomes a cancelled order.
That hooks directly into the same alerting layer I use for stuck shipments and chargeback spikes; I wrote about that setup in monitoring store health and alerting. The principle is identical: alert on a threshold of divergence, not on every single event, or you'll train yourself to ignore the channel. A one-unit rounding blip during a busy hour isn't worth a ping. A store that's drifted five units and climbing is.
Where this sits in StoreFleet
Everything above is what the inventory-sync layer in StoreFleet does for the stores we run: it dedupes on the webhook ID so a replayed event can't decrement twice, it reconciles against the Admin API on a schedule so a dropped event can't leave stock too high, and it raises an alert when two stores disagree by more than a set margin. It runs on the same unified data layer that powers our multi-store dashboard — one source of truth for orders, shipments, and stock across every store, on infrastructure you own, with no per-store fee.
I don't think overselling is a solved-once problem. It's a discipline: assume the stream lies, make your handlers idempotent, reconcile on a timer, and alert on drift. Do those four things and the same SKU on ten storefronts stops being a liability. Skip any one of them and it's a matter of traffic before a customer finds the gap for you — the way mine kept doing, right up until I stopped guessing about what Shopify actually guarantees and read the docs.