Reconciler Stock & Inventory
Stock update webhook not triggered on some mutations
Your app subscribes to PRODUCT_VARIANT_STOCK_UPDATED and it mostly works, until it does not. A staff member runs a manual stock edit and the webhook fires right on cue. Then an order gets fulfilled, Stock.quantity visibly drops in the dashboard, and nothing arrives at your endpoint. The quantity changed. Saleor just never told anyone. Here is why that gap exists and a script that finds every stock change your webhook missed.
Saleor added PRODUCT_VARIANT_STOCK_UPDATED as a narrowly wired async event. It is only emitted from the specific mutation resolvers that were explicitly updated to call stock_bulk_updated and send_webhook_request_async, namely productVariantStocksUpdate, stockBulkUpdate, and productVariantStocksCreate or Delete. Stock changes that happen as a side effect deeper in business logic, such as orderFulfill deallocating and decrementing Stock.quantity, order cancellation or refund restoring stock, or draft order completion, all mutate the Stock model directly through allocation helper functions that were never hooked up to fire the event. The quantity really changed. No webhook delivery was ever created. Run a small Python or Node.js script that snapshots stock on an interval, diffs it against the last snapshot, and cross-checks your own webhook delivery log for a matching PRODUCT_VARIANT_STOCK_UPDATED delivery. Full code, tests, and a dry run guarded reconciliation are below.
The problem in plain words
When you build an app that keeps an external inventory system in sync with Saleor, the obvious approach is to subscribe to PRODUCT_VARIANT_STOCK_UPDATED and let Saleor push you every change. For a while that looks like it works. A staff member edits stock in the dashboard, or you call stockBulkUpdate yourself, and the webhook lands right away.
Then a customer's order gets fulfilled. orderFulfill deallocates the reserved units and decrements Stock.quantity for real, the dashboard shows the new number, but your endpoint never receives a delivery. The same thing happens when an order is cancelled or refunded and Saleor restores the stock it had reserved, or when a draft order is completed and its lines consume stock for the first time. In every one of these cases the underlying Stock row changed, but the event that is supposed to announce that change was never fired, because nobody wired those code paths to call it.
Why it happens
PRODUCT_VARIANT_STOCK_UPDATEDwas added to Saleor by wiring specific mutations,productVariantStocksUpdate,stockBulkUpdate, andproductVariantStocksCreateorDelete, to callstock_bulk_updatedandsend_webhook_request_async. It was never made a generic hook on theStockmodel itself, so any code path that touchesStock.quantityoutside those resolvers stays silent, a gap tracked in saleor/saleor#11630 and saleor/saleor#11637.orderFulfilldeallocates reserved stock and decrementsStock.quantitythrough internal allocation helper functions, not through the wired mutations, so fulfilling an order changes real inventory without ever emitting the event.- Order cancellation and refunds restore stock through the same kind of allocation helper path, and draft order completion consumes stock the first time a draft becomes a real order, both mutating
Stockdirectly while bypassing the resolvers that were updated to fire the webhook. - A related, older gap in
productVariantStocksUpdateitself, documented in saleor/saleor#6479, shows this has been a recurring theme: stock mutations in Saleor have a history of not fully propagating every side effect, webhooks included, even in the mutations meant to cover it.
None of this raises an error anywhere. The order fulfills normally, the dashboard shows the correct new quantity, and the only sign something is wrong is that your external inventory system, which relies entirely on the webhook, quietly falls behind reality.
You cannot patch this by subscribing harder or retrying the webhook, because there was never a delivery to retry. The only reliable signal is ground truth stock itself. Poll productVariant or warehouses on an interval, diff it against the last snapshot, and for every quantity delta ask your own delivery log whether a matching PRODUCT_VARIANT_STOCK_UPDATED event actually arrived in that window. A delta with no matching delivery is a desync, and the recent order activity in that window tells you which non-emitting path likely caused it.
The fix, as a flow
The script runs on a schedule. It snapshots quantity and quantityAllocated per variant and warehouse, keeps the previous snapshot to diff against, and for each pair whose quantity moved it queries the app's webhook delivery log for a matching delivery in the same window. Where none is found, it classifies the desync by severity, using recent order activity as a hint for which mutation likely caused it, and reports the pair rather than writing anything. A reconciliation POST to your own external endpoint only happens when a human enables it and it is not a dry run.
Build it step by step
Get an app token with read access to stock, orders, and webhooks
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read products, stock, orders, and its own webhook deliveries, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export SALEOR_WEBHOOK_ID="gid://saleor/Webhook/1"
export RECONCILE_ENDPOINT="https://your-app.example.com/inventory/reconcile"
export DRY_RUN="true" # start safe, this script never writes without it off
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export SALEOR_WEBHOOK_ID="gid://saleor/Webhook/1"
export RECONCILE_ENDPOINT="https://your-app.example.com/inventory/reconcile"
export DRY_RUN="true" // start safe, this script never writes without it off
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
import os, requests
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
Snapshot ground truth stock, then diff against the last run
Page through warehouses and their stocks, or per-variant productVariant(id) { stocks { warehouse { id } quantity quantityAllocated } }, and record the quantity for every (variantId, warehouseId) pair. Keep the previous snapshot, keyed the same way, so you can diff successive polls and find exactly which pairs moved.
WAREHOUSES_STOCK_QUERY = """
query($cursor: String) {
warehouses(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
stocks(first: 100) {
edges { node { quantity quantityAllocated productVariant { id sku } } }
}
}
}
}
}"""
def stock_snapshot():
cursor = None
rows = {}
while True:
data = gql(WAREHOUSES_STOCK_QUERY, {"cursor": cursor})["warehouses"]
for edge in data["edges"]:
wh = edge["node"]
for stock_edge in wh["stocks"]["edges"]:
stock = stock_edge["node"]
key = (stock["productVariant"]["id"], wh["id"])
rows[key] = {
"variantId": stock["productVariant"]["id"],
"warehouseId": wh["id"],
"quantity": stock["quantity"],
"quantityAllocated": stock["quantityAllocated"],
}
if not data["pageInfo"]["hasNextPage"]:
return rows
cursor = data["pageInfo"]["endCursor"]
def diff_snapshots(previous, current):
deltas = []
for key, curr in current.items():
prev = previous.get(key)
before = prev["quantity"] if prev else curr["quantity"]
if before != curr["quantity"]:
deltas.append({
"variantId": curr["variantId"],
"warehouseId": curr["warehouseId"],
"quantityBefore": before,
"quantityAfter": curr["quantity"],
})
return deltas
const WAREHOUSES_STOCK_QUERY = `
query($cursor: String) {
warehouses(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
stocks(first: 100) {
edges { node { quantity quantityAllocated productVariant { id sku } } }
}
}
}
}
}`;
async function stockSnapshot() {
let cursor = null;
const rows = {};
while (true) {
const data = (await gql(WAREHOUSES_STOCK_QUERY, { cursor })).warehouses;
for (const edge of data.edges) {
const wh = edge.node;
for (const stockEdge of wh.stocks.edges) {
const stock = stockEdge.node;
const key = `${stock.productVariant.id}::${wh.id}`;
rows[key] = {
variantId: stock.productVariant.id,
warehouseId: wh.id,
quantity: stock.quantity,
quantityAllocated: stock.quantityAllocated,
};
}
}
if (!data.pageInfo.hasNextPage) return rows;
cursor = data.pageInfo.endCursor;
}
}
export function diffSnapshots(previous, current) {
const deltas = [];
for (const [key, curr] of Object.entries(current)) {
const prev = previous[key];
const before = prev ? prev.quantity : curr.quantity;
if (before !== curr.quantity) {
deltas.push({
variantId: curr.variantId,
warehouseId: curr.warehouseId,
quantityBefore: before,
quantityAfter: curr.quantity,
});
}
}
return deltas;
}
Check the delivery log for a matching webhook
For each delta, query your app's own webhook delivery log, webhook(id) { eventDeliveries { edges { node { eventType createdAt payload status } } } }, filtered to eventType: PRODUCT_VARIANT_STOCK_UPDATED, and look for a delivery whose payload references the same variant and warehouse with a timestamp inside the polling window. If you keep your own delivery-log table instead, query that the same way.
import json
WEBHOOK_DELIVERIES_QUERY = """
query($webhookId: ID!, $after: String) {
webhook(id: $webhookId) {
eventDeliveries(first: 100, after: $after,
filter: { eventType: PRODUCT_VARIANT_STOCK_UPDATED }) {
pageInfo { hasNextPage endCursor }
edges { node { eventType createdAt payload status } }
}
}
}"""
def deliveries_in_window(webhook_id, window_start_iso, window_end_iso):
cursor = None
matches = []
while True:
data = gql(WEBHOOK_DELIVERIES_QUERY, {"webhookId": webhook_id, "after": cursor})["webhook"]
edges = data["eventDeliveries"]["edges"]
for edge in edges:
node = edge["node"]
if window_start_iso <= node["createdAt"] <= window_end_iso:
matches.append(node)
if not data["eventDeliveries"]["pageInfo"]["hasNextPage"]:
return matches
cursor = data["eventDeliveries"]["pageInfo"]["endCursor"]
def has_matching_delivery(deliveries, variant_id, warehouse_id):
for delivery in deliveries:
try:
payload = json.loads(delivery["payload"])
except (TypeError, ValueError):
continue
if payload.get("productVariant", {}).get("id") == variant_id and \
payload.get("warehouse", {}).get("id") == warehouse_id:
return True
return False
const WEBHOOK_DELIVERIES_QUERY = `
query($webhookId: ID!, $after: String) {
webhook(id: $webhookId) {
eventDeliveries(first: 100, after: $after,
filter: { eventType: PRODUCT_VARIANT_STOCK_UPDATED }) {
pageInfo { hasNextPage endCursor }
edges { node { eventType createdAt payload status } }
}
}
}`;
async function deliveriesInWindow(webhookId, windowStartIso, windowEndIso) {
let cursor = null;
const matches = [];
while (true) {
const data = (await gql(WEBHOOK_DELIVERIES_QUERY, { webhookId, after: cursor })).webhook;
for (const edge of data.eventDeliveries.edges) {
const node = edge.node;
if (node.createdAt >= windowStartIso && node.createdAt <= windowEndIso) matches.push(node);
}
if (!data.eventDeliveries.pageInfo.hasNextPage) return matches;
cursor = data.eventDeliveries.pageInfo.endCursor;
}
}
export function hasMatchingDelivery(deliveries, variantId, warehouseId) {
for (const delivery of deliveries) {
let payload;
try {
payload = JSON.parse(delivery.payload);
} catch {
continue;
}
if (payload?.productVariant?.id === variantId && payload?.warehouse?.id === warehouseId) {
return true;
}
}
return false;
}
Decide, with one pure function
Keep the decision in its own function that takes one delta record, whether a matching delivery was found, and a hint about the recent mutation, then returns whether it is a real desync and how severe. No I/O, so it is easy to test. It is critical when the hinted cause is ORDER_FULFILL or ORDER_CANCEL, known non-emitting paths, or when the delta is large or crosses zero. Anything else with no matching delivery is a warning.
CRITICAL_HINTS = {"ORDER_FULFILL", "ORDER_CANCEL"}
CRITICAL_DELTA_RATIO = 0.10
def classify_stock_desync(record):
quantity_before = record["quantityBefore"]
quantity_after = record["quantityAfter"]
if quantity_before == quantity_after:
return {"isDesynced": False, "severity": "none", "reason": "no change"}
if record.get("matchingDeliveryFound"):
return {"isDesynced": False, "severity": "none", "reason": "webhook delivered"}
delta = quantity_after - quantity_before
hint = record.get("recentMutationHint", "UNKNOWN")
crosses_zero = (quantity_before == 0) != (quantity_after == 0)
large_delta = quantity_before != 0 and abs(delta) >= abs(quantity_before) * CRITICAL_DELTA_RATIO
if hint in CRITICAL_HINTS or large_delta or crosses_zero:
severity = "critical"
else:
severity = "warn"
reason = f"suspected {hint}, delta {delta:+d} with no matching PRODUCT_VARIANT_STOCK_UPDATED delivery"
return {"isDesynced": True, "severity": severity, "reason": reason}
const CRITICAL_HINTS = new Set(["ORDER_FULFILL", "ORDER_CANCEL"]);
const CRITICAL_DELTA_RATIO = 0.10;
export function classifyStockDesync(record) {
const { quantityBefore, quantityAfter } = record;
if (quantityBefore === quantityAfter) {
return { isDesynced: false, severity: "none", reason: "no change" };
}
if (record.matchingDeliveryFound) {
return { isDesynced: false, severity: "none", reason: "webhook delivered" };
}
const delta = quantityAfter - quantityBefore;
const hint = record.recentMutationHint || "UNKNOWN";
const crossesZero = (quantityBefore === 0) !== (quantityAfter === 0);
const largeDelta = quantityBefore !== 0 && Math.abs(delta) >= Math.abs(quantityBefore) * CRITICAL_DELTA_RATIO;
const severity = CRITICAL_HINTS.has(hint) || largeDelta || crossesZero ? "critical" : "warn";
const sign = delta >= 0 ? "+" : "";
const reason = `suspected ${hint}, delta ${sign}${delta} with no matching PRODUCT_VARIANT_STOCK_UPDATED delivery`;
return { isDesynced: true, severity, reason };
}
Report the desync, and only reconcile behind a dry run
Under DRY_RUN=true, the default, the script only logs each flagged pair with its severity and reason. There is no Saleor mutation that replays a past async event, so when DRY_RUN=false it POSTs a synthetic reconciliation payload, shaped like the real webhook payload, to the same external endpoint your subscription targets. It only calls stockBulkUpdate against Saleor itself if the external system is the trusted source and Saleor's own number is stale, never just to manufacture a webhook.
Never treat a missed webhook as something you can retroactively re-fire, Saleor has no mutation for that. Always dry run first and log the full diff, variant, warehouse, before and after quantity, and missed event count, before any write. If Saleor's own stock is correct, only your external system needs the reconciliation POST, not a fake Saleor mutation call.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, snapshots stock, diffs it against the previous run, checks the webhook delivery log, classifies every desync, and only reconciles the external endpoint when a human turns off dry run.
"""Find Saleor variant and warehouse pairs whose Stock.quantity changed
without a matching PRODUCT_VARIANT_STOCK_UPDATED webhook delivery.
PRODUCT_VARIANT_STOCK_UPDATED only fires from productVariantStocksUpdate,
stockBulkUpdate, and productVariantStocksCreate/Delete. Quantity changes
from orderFulfill, order cancellation or refund, and draft order completion
mutate Stock directly through allocation helpers that never call
stock_bulk_updated (saleor/saleor#11630, #11637, #6479), so no webhook is
ever created even though the quantity genuinely changed.
This script never re-fires a webhook, Saleor exposes no such mutation.
Under DRY_RUN=true (the default) it only reports desynced pairs. When
DRY_RUN=false it POSTs a synthetic reconciliation payload to your own
external endpoint, shaped like the real webhook payload. Run on a
schedule. Safe to run again and again.
"""
import os
import json
import time
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_stock_webhook_desync")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
WEBHOOK_ID = os.environ.get("SALEOR_WEBHOOK_ID", "")
RECONCILE_ENDPOINT = os.environ.get("RECONCILE_ENDPOINT", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CRITICAL_HINTS = {"ORDER_FULFILL", "ORDER_CANCEL"}
CRITICAL_DELTA_RATIO = 0.10
WAREHOUSES_STOCK_QUERY = """
query($cursor: String) {
warehouses(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
stocks(first: 100) {
edges { node { quantity quantityAllocated productVariant { id sku } } }
}
}
}
}
}"""
WEBHOOK_DELIVERIES_QUERY = """
query($webhookId: ID!, $after: String) {
webhook(id: $webhookId) {
eventDeliveries(first: 100, after: $after,
filter: { eventType: PRODUCT_VARIANT_STOCK_UPDATED }) {
pageInfo { hasNextPage endCursor }
edges { node { eventType createdAt payload status } }
}
}
}"""
RECENT_ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor,
filter: { updatedAt: { gte: $since } }) {
pageInfo { hasNextPage endCursor }
edges { node { id status fulfillments { id } } }
}
}"""
STOCK_BULK_UPDATE = """
mutation($variantId: ID!, $warehouseId: ID!, $quantity: Int!) {
stockBulkUpdate(stocks: [{ variantId: $variantId, warehouseId: $warehouseId, quantity: $quantity }]) {
results { stock { id quantity } errors { field message code } }
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def classify_stock_desync(record):
quantity_before = record["quantityBefore"]
quantity_after = record["quantityAfter"]
if quantity_before == quantity_after:
return {"isDesynced": False, "severity": "none", "reason": "no change"}
if record.get("matchingDeliveryFound"):
return {"isDesynced": False, "severity": "none", "reason": "webhook delivered"}
delta = quantity_after - quantity_before
hint = record.get("recentMutationHint", "UNKNOWN")
crosses_zero = (quantity_before == 0) != (quantity_after == 0)
large_delta = quantity_before != 0 and abs(delta) >= abs(quantity_before) * CRITICAL_DELTA_RATIO
if hint in CRITICAL_HINTS or large_delta or crosses_zero:
severity = "critical"
else:
severity = "warn"
reason = f"suspected {hint}, delta {delta:+d} with no matching PRODUCT_VARIANT_STOCK_UPDATED delivery"
return {"isDesynced": True, "severity": severity, "reason": reason}
def stock_snapshot():
cursor = None
rows = {}
while True:
data = gql(WAREHOUSES_STOCK_QUERY, {"cursor": cursor})["warehouses"]
for edge in data["edges"]:
wh = edge["node"]
for stock_edge in wh["stocks"]["edges"]:
stock = stock_edge["node"]
key = (stock["productVariant"]["id"], wh["id"])
rows[key] = {
"variantId": stock["productVariant"]["id"],
"warehouseId": wh["id"],
"quantity": stock["quantity"],
"quantityAllocated": stock["quantityAllocated"],
}
if not data["pageInfo"]["hasNextPage"]:
return rows
cursor = data["pageInfo"]["endCursor"]
def diff_snapshots(previous, current):
deltas = []
for key, curr in current.items():
prev = previous.get(key)
before = prev["quantity"] if prev else curr["quantity"]
if before != curr["quantity"]:
deltas.append({
"variantId": curr["variantId"],
"warehouseId": curr["warehouseId"],
"quantityBefore": before,
"quantityAfter": curr["quantity"],
})
return deltas
def deliveries_in_window(webhook_id, window_start_iso, window_end_iso):
if not webhook_id:
return []
cursor = None
matches = []
while True:
data = gql(WEBHOOK_DELIVERIES_QUERY, {"webhookId": webhook_id, "after": cursor})["webhook"]
edges = data["eventDeliveries"]["edges"]
for edge in edges:
node = edge["node"]
if window_start_iso <= node["createdAt"] <= window_end_iso:
matches.append(node)
if not data["eventDeliveries"]["pageInfo"]["hasNextPage"]:
return matches
cursor = data["eventDeliveries"]["pageInfo"]["endCursor"]
def has_matching_delivery(deliveries, variant_id, warehouse_id):
for delivery in deliveries:
try:
payload = json.loads(delivery["payload"])
except (TypeError, ValueError):
continue
if payload.get("productVariant", {}).get("id") == variant_id and \
payload.get("warehouse", {}).get("id") == warehouse_id:
return True
return False
def recent_mutation_hint(since_iso):
try:
data = gql(RECENT_ORDERS_QUERY, {"cursor": None, "since": since_iso})["orders"]
except Exception:
return "UNKNOWN"
for edge in data["edges"]:
node = edge["node"]
if node["status"] == "CANCELED":
return "ORDER_CANCEL"
if node["fulfillments"]:
return "ORDER_FULFILL"
return "UNKNOWN"
def reconcile_external(record):
if not RECONCILE_ENDPOINT:
log.info("No RECONCILE_ENDPOINT configured, skipping external POST.")
return
payload = {
"productVariant": {"id": record["variantId"]},
"warehouse": {"id": record["warehouseId"]},
"quantity": record["quantityAfter"],
"quantityAllocated": record.get("quantityAllocated"),
}
if DRY_RUN:
log.info("Would POST reconciliation payload: %s", payload)
return
r = requests.post(RECONCILE_ENDPOINT, json=payload, timeout=30)
r.raise_for_status()
def run(previous_snapshot=None):
previous_snapshot = previous_snapshot or {}
current_snapshot = stock_snapshot()
deltas = diff_snapshots(previous_snapshot, current_snapshot)
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
window_start_iso = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)).isoformat()
deliveries = deliveries_in_window(WEBHOOK_ID, window_start_iso, now_iso)
hint = recent_mutation_hint(window_start_iso)
flagged = []
for delta in deltas:
found = has_matching_delivery(deliveries, delta["variantId"], delta["warehouseId"])
record = {**delta, "matchingDeliveryFound": found, "recentMutationHint": hint}
result = classify_stock_desync(record)
if not result["isDesynced"]:
continue
flagged.append({**record, **result})
log.warning(
"DESYNC severity=%s variant=%s warehouse=%s before=%d after=%d reason=%s",
result["severity"], delta["variantId"], delta["warehouseId"],
delta["quantityBefore"], delta["quantityAfter"], result["reason"],
)
for record in flagged:
reconcile_external(record)
log.info("Done. %d desynced pair(s) %s.", len(flagged), "would reconcile" if DRY_RUN else "reconciled")
return current_snapshot, flagged
if __name__ == "__main__":
run()
/**
* Find Saleor variant and warehouse pairs whose Stock.quantity changed
* without a matching PRODUCT_VARIANT_STOCK_UPDATED webhook delivery.
*
* PRODUCT_VARIANT_STOCK_UPDATED only fires from productVariantStocksUpdate,
* stockBulkUpdate, and productVariantStocksCreate/Delete. Quantity changes
* from orderFulfill, order cancellation or refund, and draft order completion
* mutate Stock directly through allocation helpers that never call
* stock_bulk_updated (saleor/saleor#11630, #11637, #6479), so no webhook is
* ever created even though the quantity genuinely changed.
*
* This script never re-fires a webhook, Saleor exposes no such mutation.
* Under DRY_RUN=true (the default) it only reports desynced pairs. When
* DRY_RUN=false it POSTs a synthetic reconciliation payload to your own
* external endpoint, shaped like the real webhook payload. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/stock-update-webhook-not-triggered/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const WEBHOOK_ID = process.env.SALEOR_WEBHOOK_ID || "";
const RECONCILE_ENDPOINT = process.env.RECONCILE_ENDPOINT || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CRITICAL_HINTS = new Set(["ORDER_FULFILL", "ORDER_CANCEL"]);
const CRITICAL_DELTA_RATIO = 0.10;
export function classifyStockDesync(record) {
const { quantityBefore, quantityAfter } = record;
if (quantityBefore === quantityAfter) {
return { isDesynced: false, severity: "none", reason: "no change" };
}
if (record.matchingDeliveryFound) {
return { isDesynced: false, severity: "none", reason: "webhook delivered" };
}
const delta = quantityAfter - quantityBefore;
const hint = record.recentMutationHint || "UNKNOWN";
const crossesZero = (quantityBefore === 0) !== (quantityAfter === 0);
const largeDelta = quantityBefore !== 0 && Math.abs(delta) >= Math.abs(quantityBefore) * CRITICAL_DELTA_RATIO;
const severity = CRITICAL_HINTS.has(hint) || largeDelta || crossesZero ? "critical" : "warn";
const sign = delta >= 0 ? "+" : "";
const reason = `suspected ${hint}, delta ${sign}${delta} with no matching PRODUCT_VARIANT_STOCK_UPDATED delivery`;
return { isDesynced: true, severity, reason };
}
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const WAREHOUSES_STOCK_QUERY = `
query($cursor: String) {
warehouses(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
name
stocks(first: 100) {
edges { node { quantity quantityAllocated productVariant { id sku } } }
}
}
}
}
}`;
const WEBHOOK_DELIVERIES_QUERY = `
query($webhookId: ID!, $after: String) {
webhook(id: $webhookId) {
eventDeliveries(first: 100, after: $after,
filter: { eventType: PRODUCT_VARIANT_STOCK_UPDATED }) {
pageInfo { hasNextPage endCursor }
edges { node { eventType createdAt payload status } }
}
}
}`;
const RECENT_ORDERS_QUERY = `
query($cursor: String, $since: DateTime) {
orders(first: 50, after: $cursor,
filter: { updatedAt: { gte: $since } }) {
pageInfo { hasNextPage endCursor }
edges { node { id status fulfillments { id } } }
}
}`;
const STOCK_BULK_UPDATE = `
mutation($variantId: ID!, $warehouseId: ID!, $quantity: Int!) {
stockBulkUpdate(stocks: [{ variantId: $variantId, warehouseId: $warehouseId, quantity: $quantity }]) {
results { stock { id quantity } errors { field message code } }
}
}`;
async function stockSnapshot() {
let cursor = null;
const rows = {};
while (true) {
const data = (await gql(WAREHOUSES_STOCK_QUERY, { cursor })).warehouses;
for (const edge of data.edges) {
const wh = edge.node;
for (const stockEdge of wh.stocks.edges) {
const stock = stockEdge.node;
const key = `${stock.productVariant.id}::${wh.id}`;
rows[key] = {
variantId: stock.productVariant.id,
warehouseId: wh.id,
quantity: stock.quantity,
quantityAllocated: stock.quantityAllocated,
};
}
}
if (!data.pageInfo.hasNextPage) return rows;
cursor = data.pageInfo.endCursor;
}
}
export function diffSnapshots(previous, current) {
const deltas = [];
for (const [key, curr] of Object.entries(current)) {
const prev = previous[key];
const before = prev ? prev.quantity : curr.quantity;
if (before !== curr.quantity) {
deltas.push({
variantId: curr.variantId,
warehouseId: curr.warehouseId,
quantityBefore: before,
quantityAfter: curr.quantity,
});
}
}
return deltas;
}
async function deliveriesInWindow(webhookId, windowStartIso, windowEndIso) {
if (!webhookId) return [];
let cursor = null;
const matches = [];
while (true) {
const data = (await gql(WEBHOOK_DELIVERIES_QUERY, { webhookId, after: cursor })).webhook;
for (const edge of data.eventDeliveries.edges) {
const node = edge.node;
if (node.createdAt >= windowStartIso && node.createdAt <= windowEndIso) matches.push(node);
}
if (!data.eventDeliveries.pageInfo.hasNextPage) return matches;
cursor = data.eventDeliveries.pageInfo.endCursor;
}
}
export function hasMatchingDelivery(deliveries, variantId, warehouseId) {
for (const delivery of deliveries) {
let payload;
try {
payload = JSON.parse(delivery.payload);
} catch {
continue;
}
if (payload?.productVariant?.id === variantId && payload?.warehouse?.id === warehouseId) {
return true;
}
}
return false;
}
async function recentMutationHint(sinceIso) {
let data;
try {
data = (await gql(RECENT_ORDERS_QUERY, { cursor: null, since: sinceIso })).orders;
} catch {
return "UNKNOWN";
}
for (const edge of data.edges) {
const node = edge.node;
if (node.status === "CANCELED") return "ORDER_CANCEL";
if (node.fulfillments.length) return "ORDER_FULFILL";
}
return "UNKNOWN";
}
async function reconcileExternal(record) {
if (!RECONCILE_ENDPOINT) {
console.log("No RECONCILE_ENDPOINT configured, skipping external POST.");
return;
}
const payload = {
productVariant: { id: record.variantId },
warehouse: { id: record.warehouseId },
quantity: record.quantityAfter,
quantityAllocated: record.quantityAllocated,
};
if (DRY_RUN) {
console.log("Would POST reconciliation payload:", payload);
return;
}
const res = await fetch(RECONCILE_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Reconcile endpoint ${res.status}`);
}
export async function run(previousSnapshot = {}) {
const currentSnapshot = await stockSnapshot();
const deltas = diffSnapshots(previousSnapshot, currentSnapshot);
const nowIso = new Date().toISOString();
const windowStartIso = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const deliveries = await deliveriesInWindow(WEBHOOK_ID, windowStartIso, nowIso);
const hint = await recentMutationHint(windowStartIso);
const flagged = [];
for (const delta of deltas) {
const found = hasMatchingDelivery(deliveries, delta.variantId, delta.warehouseId);
const record = { ...delta, matchingDeliveryFound: found, recentMutationHint: hint };
const result = classifyStockDesync(record);
if (!result.isDesynced) continue;
flagged.push({ ...record, ...result });
console.warn(
`DESYNC severity=${result.severity} variant=${delta.variantId} warehouse=${delta.warehouseId} before=${delta.quantityBefore} after=${delta.quantityAfter} reason=${result.reason}`
);
}
for (const record of flagged) {
await reconcileExternal(record);
}
console.log(`Done. ${flagged.length} desynced pair(s) ${DRY_RUN ? "would reconcile" : "reconciled"}.`);
return { currentSnapshot, flagged };
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which desyncs get escalated as critical. Because classify_stock_desync is pure, the test needs no network and no Saleor account. It just feeds in plain records and checks the answer.
from detect_stock_webhook_desync import classify_stock_desync
def record(**over):
base = {
"variantId": "gid://saleor/ProductVariant/1",
"warehouseId": "gid://saleor/Warehouse/1",
"quantityBefore": 20,
"quantityAfter": 15,
"matchingDeliveryFound": False,
"recentMutationHint": "UNKNOWN",
}
base.update(over)
return base
def test_no_desync_when_quantity_unchanged():
result = classify_stock_desync(record(quantityBefore=10, quantityAfter=10))
assert result == {"isDesynced": False, "severity": "none", "reason": "no change"}
def test_no_desync_when_delivery_found():
result = classify_stock_desync(record(matchingDeliveryFound=True))
assert result == {"isDesynced": False, "severity": "none", "reason": "webhook delivered"}
def test_critical_when_order_fulfill_hint():
result = classify_stock_desync(record(recentMutationHint="ORDER_FULFILL"))
assert result["isDesynced"] is True
assert result["severity"] == "critical"
def test_critical_when_order_cancel_hint():
result = classify_stock_desync(record(recentMutationHint="ORDER_CANCEL"))
assert result["severity"] == "critical"
def test_critical_when_crosses_zero():
result = classify_stock_desync(record(quantityBefore=0, quantityAfter=5, recentMutationHint="UNKNOWN"))
assert result["severity"] == "critical"
def test_critical_when_large_delta():
result = classify_stock_desync(record(quantityBefore=100, quantityAfter=85, recentMutationHint="UNKNOWN"))
assert result["severity"] == "critical"
def test_warn_when_small_unknown_delta():
result = classify_stock_desync(record(quantityBefore=100, quantityAfter=99, recentMutationHint="UNKNOWN"))
assert result == {
"isDesynced": True,
"severity": "warn",
"reason": "suspected UNKNOWN, delta -1 with no matching PRODUCT_VARIANT_STOCK_UPDATED delivery",
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStockDesync } from "./detect-stock-webhook-desync.js";
const record = (over = {}) => ({
variantId: "gid://saleor/ProductVariant/1",
warehouseId: "gid://saleor/Warehouse/1",
quantityBefore: 20,
quantityAfter: 15,
matchingDeliveryFound: false,
recentMutationHint: "UNKNOWN",
...over,
});
test("no desync when quantity unchanged", () => {
const result = classifyStockDesync(record({ quantityBefore: 10, quantityAfter: 10 }));
assert.deepEqual(result, { isDesynced: false, severity: "none", reason: "no change" });
});
test("no desync when delivery found", () => {
const result = classifyStockDesync(record({ matchingDeliveryFound: true }));
assert.deepEqual(result, { isDesynced: false, severity: "none", reason: "webhook delivered" });
});
test("critical when order fulfill hint", () => {
const result = classifyStockDesync(record({ recentMutationHint: "ORDER_FULFILL" }));
assert.equal(result.isDesynced, true);
assert.equal(result.severity, "critical");
});
test("critical when order cancel hint", () => {
const result = classifyStockDesync(record({ recentMutationHint: "ORDER_CANCEL" }));
assert.equal(result.severity, "critical");
});
test("critical when crosses zero", () => {
const result = classifyStockDesync(record({ quantityBefore: 0, quantityAfter: 5, recentMutationHint: "UNKNOWN" }));
assert.equal(result.severity, "critical");
});
test("critical when large delta", () => {
const result = classifyStockDesync(record({ quantityBefore: 100, quantityAfter: 85, recentMutationHint: "UNKNOWN" }));
assert.equal(result.severity, "critical");
});
test("warn when small unknown delta", () => {
const result = classifyStockDesync(record({ quantityBefore: 100, quantityAfter: 99, recentMutationHint: "UNKNOWN" }));
assert.deepEqual(result, {
isDesynced: true,
severity: "warn",
reason: "suspected UNKNOWN, delta -1 with no matching PRODUCT_VARIANT_STOCK_UPDATED delivery",
});
});
Case studies
A fulfillment partner kept overselling a bestseller
A sneaker resale app synced Saleor stock to a third party logistics dashboard purely through the PRODUCT_VARIANT_STOCK_UPDATED subscription. Manual restocks always synced fine. But every time an order fulfilled, the 3PL dashboard kept showing the old, higher quantity, because orderFulfill never triggered a delivery, and the bestseller kept getting oversold on the 3PL side.
Running the reconciler hourly caught every fulfillment-driven drop as a critical desync tagged ORDER_FULFILL, and the POST to the 3PL's own reconciliation endpoint closed the gap the same day instead of after a week of oversold orders.
Cancelled orders quietly restocked without telling anyone
A home goods brand's finance team relied on the webhook to know when cancelled orders freed up stock again, so they could re-list a variant that looked sold out. Cancellations restored Stock.quantity correctly in Saleor, but the webhook never fired, so the external listing tool kept the variant hidden as unavailable for days after it was back.
The nightly diff flagged those pairs as critical, tagged ORDER_CANCEL, letting the team push the corrected quantity to the listing tool the same night the cancellation happened, instead of losing sales on stock that was already back.
After this runs on a schedule, a stock change that Saleor's own webhook silently dropped gets caught within the hour instead of surfacing as an oversold order or a listing that never came back. The team gets the exact variant, warehouse, before and after quantity, and the suspected cause, and any correction to an external system stays a deliberate, dry-run-first action, never a blind rewrite chasing a webhook that was never going to fire.
FAQ
Why does PRODUCT_VARIANT_STOCK_UPDATED not fire when an order is fulfilled?
PRODUCT_VARIANT_STOCK_UPDATED is only wired into the specific mutation resolvers that were explicitly updated to call stock_bulk_updated and send the async event, such as productVariantStocksUpdate, stockBulkUpdate, and productVariantStocksCreate or Delete. orderFulfill deallocates and decrements Stock.quantity through allocation helper functions deeper in the business logic that were never hooked up to fire the event, so the quantity genuinely changes but no webhook delivery is ever created.
How do I detect a stock change that never triggered a webhook?
Poll ground truth stock on an interval with a query like productVariant stocks quantity quantityAllocated per warehouse, snapshot it, and diff successive snapshots keyed by variant and warehouse. For every quantity delta, check the app own webhook delivery log filtered to eventType PRODUCT_VARIANT_STOCK_UPDATED for a delivery with a matching variant, warehouse, and timestamp in that window. A delta with no matching delivery is a desync you can attribute to the recent order activity in the same window.
Can I just call stockBulkUpdate to make the webhook fire retroactively?
No. Saleor does not expose a mutation that replays a past async event, and calling stockBulkUpdate on a quantity that is already correct in Saleor's own database only manufactures a fake audit trail. Treat a confirmed desync as a report, and only write back to your external inventory system directly, or call stockBulkUpdate against Saleor when Saleor's own stock is stale and the external system is the trusted source, always behind a dry run.
Related field notes
Citations
On the problem:
- Bug: Stock update mutations don't trigger webhooks. github.com/saleor/saleor/issues/11630
- Bug: Stock update webhook is not triggered. github.com/saleor/saleor/issues/11637
- productVariantStocksUpdate mutation does not fully update stock. github.com/saleor/saleor/issues/6479
On the solution:
- Saleor Commerce Documentation: ProductVariantStockUpdated Object. docs.saleor.io/api-reference/products/objects/product-variant-stock-updated
- Saleor Commerce Documentation: stockBulkUpdate Mutation. docs.saleor.io/api-reference/products/mutations/stock-bulk-update
- Saleor Commerce Documentation: Webhooks Overview. docs.saleor.io/developer/extending/webhooks/overview
Stuck on a tricky one?
If you have a problem in Saleor checkout, stock, channels, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a missed webhook for you?
If this saved you from an oversold order or a stale external inventory feed, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi