DEV Community

Fahad Azhar
Fahad Azhar

Posted on

Shopify can't tell you who changed your stock, so I built a ledger for 4 stores

A product shows 12 in stock. Last week it showed 40. Was that 28 sales, a recount, a refund or an app? Shopify gives you the number, but not a usable history, and there's no Admin API for past adjustments. I run four Shopify stores (UK, Pakistan, Saudi Arabia, UAE), so I built the history myself.

The audit came first

Before building anything, I exported every stock level across the four stores:

  • 108 SKUs at negative stock
  • 19 SKUs above 2,000 units, one at 73,688
  • 81% "out of stock" on the UK store, inflated by 5,309 levels on draft products

A ledger that starts from wrong numbers just records wrong numbers. We cleaned up first.

Shopify keeps the number, Postgres keeps the reason

Staff still edit stock in Shopify, and POS and apps still read from it. On top of that, every change is also written to Postgres (Supabase): store, location, variant, before → after, reason, and who changed it. The starting point was an opening balance of 11,544 levels.

Webhooks tell you a level changed. They don't tell you why. So a job runs every 5 minutes and labels each change in this order:

  1. Dashboard intent: written before our own change is sent to Shopify
  2. Sale: an order for that variant within −20/+10 minutes
  3. Cancellation: a cancelled order in the same window
  4. Shopify admin: still unexplained after 60 minutes

That last group, the changes nobody made through the dashboard, is the one the owner checks most. A nightly job compares the ledger with Shopify, and the result has to be 0 mismatches across all four stores. It is.

Dashboard changes are atomic

Receipts, adjustments, transfers and counts are numbered documents. Posting one:

  1. locks the document
  2. reads the live quantity from Shopify
  3. blocks anything that would go below zero
  4. writes the intents
  5. sends one inventoryAdjustQuantities call, so the whole document applies or none of it does

If Shopify already accepted the change and something fails afterwards, the document is marked failed and can never go back to draft. You can't apply the same transfer twice.

229,703 calls a day, and a bug that hid behind HTTP 300

The project runs on Supabase's free plan (5 GB egress a month), and in October it got blocked for about a week. The logs showed 229,703 REST calls in 24 hours from edge functions, against 507 from real users.

There were two causes:

  • Product sync: it made separate calls for every product, every variant, and two for every tag
  • Courier tracking: it called a database function once per event, and that function had two overloads. Postgres couldn't pick one, so all 84,818 calls returned HTTP 300. Nothing errored loudly, and no tracking events were saved.

The fix was to batch everything:

create or replace function record_tracking_events(p_events jsonb)
returns int language sql as $$
  with ins as (
    insert into tracking_events (order_id, status, happened_at)
    select (e->>'order_id')::bigint, e->>'status', (e->>'at')::timestamptz
    from jsonb_array_elements(p_events) e
    on conflict do nothing
    returning 1
  )
  select count(*)::int from ins
$$;
Enter fullscreen mode Exit fullscreen mode

Product sync now writes about 7 upserts per page of 25 products instead of hundreds of single calls.

Takeaways

  • Audit your numbers before you build the ledger.
  • Let Shopify own the quantity; you own the reason.
  • Write the reason down before you make your own change.
  • Make multi-line stock changes atomic, and make "already applied" impossible to undo by accident.
  • Monitor call volume, not just errors. My worst bug here returned HTTP 300 and stayed quiet.

I'm Fahad, a Shopify and full-stack developer. More write-ups at fahadazhar.site. How do you track stock changes across stores? Tell me in the comments.

Top comments (0)