Skip to content

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.

Python and Node.js Saleor GraphQL API Report only (no auto writes)
Stacks of wooden pallets
Photo by Sergej on Unsplash
The short answer

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.

orderFulfill runs deallocates, decrements Stock.quantity Quantity really changes helper never calls stock_bulk_updated No event fired no webhook delivery External system out of sync
The Stock row really changes when an order is fulfilled, but that code path never calls the function that fires PRODUCT_VARIANT_STOCK_UPDATED, so nothing is delivered and your external system silently drifts.

Why it happens

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.

The key insight

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.

Scheduled job runs on a timer Snapshot stock, diff against prior snapshot Check delivery log for matching webhook event No delivery found? yes no, delivered fine Classify, report, reconcile if enabled POST to your own endpoint, dry run first
The script always reports a desync first. It only POSTs a reconciliation payload to your own external endpoint when DRY_RUN is off, and it never re-fires a real Saleor webhook, since Saleor has no mutation for that.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

deliveries.py
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
deliveries.js
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;
}
5

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.

decide.py
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}
decide.js
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 };
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
detect_stock_webhook_desync.py
"""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()
detect-stock-webhook-desync.js
/**
 * 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.

test_stock_desync.py
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",
    }
desync.test.js
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

3PL integration

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.

Refund handling

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.

What good looks like

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:

  1. Bug: Stock update mutations don't trigger webhooks. github.com/saleor/saleor/issues/11630
  2. Bug: Stock update webhook is not triggered. github.com/saleor/saleor/issues/11637
  3. productVariantStocksUpdate mutation does not fully update stock. github.com/saleor/saleor/issues/6479

On the solution:

  1. Saleor Commerce Documentation: ProductVariantStockUpdated Object. docs.saleor.io/api-reference/products/objects/product-variant-stock-updated
  2. Saleor Commerce Documentation: stockBulkUpdate Mutation. docs.saleor.io/api-reference/products/mutations/stock-bulk-update
  3. 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.

Contact me on LinkedIn

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

Back to all Saleor field notes