OrderOpsBETA

Field notesEngineering

Refunds, restocks, and the double-up bug

A customer pays at 9:00 and changes their mind at 9:40. You cancel the order and refund it. One question: does the stock come back once, or twice? It should be a silly question. Shopify makes it a real one — cancel-with-refund fires two webhooks, orders/cancelled and refunds/create, and both of them would like to restock your three mugs.

Defence one: idempotency keys

Every restock writes an inventory mutation with an idempotency key built from the organisation, store, order, reason, and refund id — unique-constrained in the database. When Shopify retries a delivery, and it retries a lot, the duplicate short-circuits before it touches stock. Each mutation also stores a before/after snapshot, so the audit trail shows exactly what every restock did, and supported actions can be rolled back.

But idempotency keys only dedupe the same event. A cancellation and a refund are different events with different keys, pointing at the same three mugs.

The fix that was wrong

Our first fix special-cased it: if an order was fully unfulfilled, the refund handler skipped any line marked restock_type "cancel" and let the cancellation handler own the restock. Tidy, well-tested, and built on a false premise. A later audit proved that "cancel" restock lines aren't exclusive to cancellations — so on a live order, a refund-with-restock now restocked nothing at all. We'd traded a double for a zero, which is worse, because nobody notices a zero.

The fix that stuck: a shared ledger

The real answer was to stop arguing about which webhook owns the restock. Shopify already tracks how many units of each line are still unfulfilled — the fulfillable quantity. That number is now the restock ledger. Every restock, from either webhook, debits it: restock the minimum of what was requested and what's still fulfillable. Whichever event arrives first takes the units; the second finds the ledger empty and does nothing. Arrival order stops mattering.

order #1042 — 3 × MUG-BLK, unfulfilled
fulfillable ledger: 3

orders/cancelled → restock min(3, 3) = 3   ledger → 0
refunds/create   → restock min(3, 0) = 0   no-op

swap the arrival order: same totals.

The same audit pushed us to a broader rule: one writer per direction. The orders webhook owns stock decreases; the inventory webhook owns increases. Two writers racing in the same direction had already produced a sister bug — a manual stock change racing an order deduction into a double-deduct — and the single-writer rule killed that class of problem outright.

The honest caveat: this is eventual consistency over webhooks and background jobs, not a distributed transaction. Counts can take a moment to settle, and if a human also clicks restock inside Shopify admin, that's a genuine increase we'll mirror. When numbers drift anyway, reconcile per SKU against live Shopify levels — that's what the reconciliation page is for.

Refunds, restocks, and the double-up bug — OrderOps