Skip to content

Diagnostic Inventory (MSI)

Shared stock not synced across websites

Two storefronts were supposed to draw from one warehouse. On paper they still are, since nobody touched the product data or the source quantity. But one website keeps selling units the other one already sold, and the totals never line up no matter how many times someone reindexes. The stock was never actually shared. Here is why that mapping quietly drifts and a small script that proves it, website by website, before you touch anything.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
A worker with a tablet in a warehouse
Photo by Rodrigo Rodrigues on Unsplash
The short answer

In MSI, salable quantity is computed per stock_id, source item quantity assigned to that stock minus every outstanding reservation keyed by SKU and stock_id, and any websites whose sales channel resolves to the same stock_id are meant to draw down one shared pool. "Not synced" oversell almost always means the websites are not actually on the same stock_id anymore. A website's sales channel to stock mapping was changed or misconfigured, in Stores, Configuration, Sales Channels or through the inventory stocks API, so it silently resolves to a different stock than assumed, or legacy or third party code wrote quantity directly into the deprecated cataloginventory_stock_item table instead of creating a reservation, bypassing the reservation ledger entirely. Run a small Python or Node.js script that resolves each website's actual stock_id, calls get-product-salable-quantity for every website believed to share stock, and flags any website whose stock_id has drifted from the group, or whose salable quantity disagrees with siblings that do share the same stock_id. The script only reports, since reassigning a stock or repairing legacy writes is a deliberate human decision. Full code, tests, and a dry run guard are below.

The problem in plain words

MSI never asks "which website is this." It asks "which stock_id is this." Every sales channel, and every website is a sales channel, points at exactly one stock. When two websites point at the same stock, Magento sums the source items assigned to that stock and subtracts every reservation ever written against that SKU and stock_id, and both websites see the same number because they are, underneath, reading the same pool.

The whole idea falls apart the moment that pointer stops matching what a merchant assumes it is. Nobody has to touch a product, a quantity, or a source for this to happen. Someone changes the sales channel assignment for one website while reorganizing stocks, or an integration calls the inventory stocks API and quietly reassigns a channel, and from that point on the two websites are reading two separate reservation ledgers even though the merchandising team still believes they share one warehouse. A second, less obvious cause does not touch the mapping at all: some legacy or third party code writes quantity straight into the old single source table, cataloginventory_stock_item, instead of creating a reservation. MSI reservations are append only and scoped strictly by stock_id, so a write that skips that ledger never appears in either website's salable quantity calculation, and the number one website reports quietly stops matching reality.

Website A assumed stock_id 1 Website B assumed stock_id 1 Sales channel mapping changed, or a legacy write skipped the reservation stock_id 1 its own reservation ledger stock_id 2 a different ledger entirely Oversell on Website A Oversell on Website B
Both websites still believe they share stock_id 1. One of them silently does not, or a write bypassed the shared ledger entirely, so each keeps selling against a pool the other has already drawn down.

Why it happens

None of this requires a bug in the salable quantity subtraction, that arithmetic is fine per stock_id. The gap opens because the mapping from website to stock, or the write path into stock, silently stopped matching what the merchant assumes. A few concrete ways it shows up on real stores:

None of this throws an error a merchant would see. The catalog looks fine, the source item quantity looks fine, and each website's own admin grid looks internally consistent. It is only when you compare the two websites side by side, at the same instant, for the same SKU, that the drift shows up. See the citations at the end for the exact wiki pages and issue thread that describe this.

The key insight

A website does not share stock because a merchant intends it to. It shares stock only when its sales channel resolves to the same stock_id as the other website, right now. So the first thing any diagnostic has to do is stop assuming the mapping is correct and instead resolve it fresh for every website under test. Only after confirming two websites share a stock_id does a quantity mismatch between them mean something, since it then points at a bypass of the reservation system rather than a mapping problem.

The fix, as a flow

We never reassign a stock or touch product data from this script, since that is a deliberate architecture decision. Instead, for each website under test we resolve its actual stock_id, call get-product-salable-quantity for the SKU on that stock_id, and group the results. Any website whose stock_id does not match the expected shared stock is flagged as drifted. Among websites that do share a stock_id, any difference in reported salable quantity for the same SKU at the same instant is flagged as a reservation bypass.

Fetch per website stock_id, salable qty Group websites by resolved stock_id not the assumed one Compare qty within each stock_id group Drift or mismatch? yes Report and exit non-zero, for a human no In sync nothing written
The script only ever reports. Reassigning a stock or fixing legacy writes stays a deliberate action taken by a human in the admin or on the server.

Build it step by step

1

Get an admin token

Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL, credentials, the SKU under test, and the website codes you expect to share stock in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export SKU="SKU-1001"
export EXPECTED_SHARED_STOCK_ID="1"
export WEBSITE_CODES="base,eu_website"
export DRY_RUN="true"   # report only, this script never reassigns a stock
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export SKU="SKU-1001"
export EXPECTED_SHARED_STOCK_ID="1"
export WEBSITE_CODES="base,eu_website"
export DRY_RUN="true"   // report only, this script never reassigns a stock
2

Confirm the SKU is even assigned to each website

POST to /rest/V1/integration/admin/token for a bearer token, then call /rest/V1/products/{sku}/websites to see which websites the SKU is assigned to at all. A website that is not in that list cannot possibly be selling the SKU, so this rules out a much simpler explanation before we go looking for a stock mapping problem.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")

def get_token(username, password):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": username, "password": password},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def product_website_ids(token, sku):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/products/{sku}/websites",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");

async function getToken(username, password) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function productWebsiteIds(token, sku) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}/websites`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Resolve each website's actual stock_id and salable quantity

Do not assume a website's stock_id matches what the merchant intends. Resolve it from the website's sales channel, then call /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId} for that resolved stock_id. Also pull /rest/V1/inventory/source-items filtered by SKU so the report can show the per-source quantities feeding the pool.

step3.py
def resolve_stock_id_for_website(token, website_code):
    # Resolved from the sales channel to stock assignment for this website.
    # See Stores > Configuration > Sales Channels, or the inventory/stocks API.
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/stock-resolver/website/{website_code}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def salable_qty(token, sku, stock_id):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/{sku}/{stock_id}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def source_items_for_sku(token, sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/source-items",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json().get("items", [])
step3.js
async function resolveStockIdForWebsite(token, websiteCode) {
  // Resolved from the sales channel to stock assignment for this website.
  // See Stores > Configuration > Sales Channels, or the inventory/stocks API.
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/stock-resolver/website/${encodeURIComponent(websiteCode)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function salableQty(token, sku, stockId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/${encodeURIComponent(sku)}/${stockId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function sourceItemsForSku(token, sku) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/source-items?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  return body.items || [];
}
4

Decide, with one pure function

Keep the comparison in its own function that takes only already fetched reports, one per website, and the expected shared stock_id, and returns a verdict. It has no I/O, so it is trivial to unit test with fixed inputs and it never depends on the network being up. It flags two different problems separately: a website whose stock_id has drifted from the expected shared stock, and a quantity mismatch between websites that do share a stock_id.

detect.py
def detect_stock_desync(website_stock_reports, expected_shared_stock_id):
    drifted_websites = []
    qty_mismatches = []

    for report in website_stock_reports:
        if report["stock_id"] != expected_shared_stock_id:
            drifted_websites.append(report["website_code"])

    in_sync_group = [r for r in website_stock_reports if r["stock_id"] == expected_shared_stock_id]
    if in_sync_group:
        base_qty = in_sync_group[0]["salable_qty"]
        for report in in_sync_group:
            if report["salable_qty"] != base_qty:
                qty_mismatches.append({
                    "website_code": report["website_code"],
                    "salable_qty": report["salable_qty"],
                })

    in_sync = not drifted_websites and not qty_mismatches
    return {
        "inSync": in_sync,
        "driftedWebsites": drifted_websites,
        "qtyMismatches": qty_mismatches,
    }
detect.js
export function detectStockDesync(websiteStockReports, expectedSharedStockId) {
  const driftedWebsites = [];
  const qtyMismatches = [];

  for (const report of websiteStockReports) {
    if (report.stock_id !== expectedSharedStockId) {
      driftedWebsites.push(report.website_code);
    }
  }

  const inSyncGroup = websiteStockReports.filter((r) => r.stock_id === expectedSharedStockId);
  if (inSyncGroup.length) {
    const baseQty = inSyncGroup[0].salable_qty;
    for (const report of inSyncGroup) {
      if (report.salable_qty !== baseQty) {
        qtyMismatches.push({ website_code: report.website_code, salable_qty: report.salable_qty });
      }
    }
  }

  const inSync = driftedWebsites.length === 0 && qtyMismatches.length === 0;
  return { inSync, driftedWebsites, qtyMismatches };
}
5

Cross-check against recent orders, then only report

Pull /rest/V1/orders filtered by created_at window and store or website to confirm a recent purchase in one website should have decremented the shared pool. There is no safe REST write here. Reassigning a website's stock is a deliberate admin decision, and repairing a legacy write into cataloginventory_stock_item needs a CLI reindex plus manual reservation reconciliation. The script only emits the structured report and exits non-zero.

recent_orders.py
def recent_orders_for_sku(token, sku, created_at_from):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][value]": created_at_from,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[pageSize]": 100,
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/orders",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    matches = []
    for order in r.json().get("items", []):
        for line in order.get("items", []):
            if line.get("sku") == sku:
                matches.append(order.get("entity_id"))
    return matches
recent-orders.js
async function recentOrdersForSku(token, sku, createdAtFrom) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][value]": createdAtFrom,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[pageSize]": "100",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  const matches = [];
  for (const order of body.items || []) {
    for (const line of order.items || []) {
      if (line.sku === sku) matches.push(order.entity_id);
    }
  }
  return matches;
}
6

Wire it together with a dry run guard

The run authenticates once, checks the SKU is assigned to every configured website, resolves each website's stock_id and salable quantity, runs the pure detection function, and prints a structured report. DRY_RUN defaults to true and there is no path that reassigns a stock or writes product data, this script only ever reads and reports, then exits non-zero when a desync is found so a human notices in CI or a cron log.

Run it safe

This script never calls a REST endpoint that mutates stock, sales channels, or product data. It reports the websites, their resolved stock_id, each website's salable quantity, and the source items sum feeding that stock, then it exits non-zero so a human can correct the mapping in Stores, Configuration, Sales Channels, or investigate a legacy write path. The correction always happens on the server, never through this script.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever performs reads, plus a non-zero exit when a desync is flagged.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
detect_stock_desync.py
"""Detect Magento 2 or Adobe Commerce websites whose shared stock has drifted
out of sync.

MSI computes salable quantity per stock_id, source item quantity assigned to
that stock minus every outstanding reservation keyed by SKU and stock_id. Two
websites only share one pool of stock when their sales channels both resolve
to the same stock_id. "Not synced" oversell almost always means that mapping
drifted, a website's sales channel was reassigned to a different stock, or
some legacy or third party code wrote quantity directly into the deprecated
cataloginventory_stock_item table instead of creating a reservation, bypassing
the reservation ledger entirely. This script resolves each website's actual
stock_id, reads its salable quantity for a SKU, and flags any drift or
mismatch. It never reassigns a stock or writes product data: that stays a
deliberate admin decision made in Stores, Configuration, Sales Channels, plus
a CLI reindex and manual reservation reconciliation for legacy write paths.
Safe to run again and again.
"""
import os
import sys
import json
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_stock_desync")

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
SKU = os.environ.get("SKU", "")
EXPECTED_SHARED_STOCK_ID = int(os.environ.get("EXPECTED_SHARED_STOCK_ID", "1"))
WEBSITE_CODES = [w.strip() for w in os.environ.get("WEBSITE_CODES", "").split(",") if w.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def get_token():
    if ADMIN_TOKEN:
        return ADMIN_TOKEN
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def product_website_ids(token, sku):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/products/{sku}/websites",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def resolve_stock_id_for_website(token, website_code):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/stock-resolver/website/{website_code}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def salable_qty(token, sku, stock_id):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/{sku}/{stock_id}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def source_items_for_sku(token, sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/source-items",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json().get("items", [])


def detect_stock_desync(website_stock_reports, expected_shared_stock_id):
    drifted_websites = []
    qty_mismatches = []

    for report in website_stock_reports:
        if report["stock_id"] != expected_shared_stock_id:
            drifted_websites.append(report["website_code"])

    in_sync_group = [r for r in website_stock_reports if r["stock_id"] == expected_shared_stock_id]
    if in_sync_group:
        base_qty = in_sync_group[0]["salable_qty"]
        for report in in_sync_group:
            if report["salable_qty"] != base_qty:
                qty_mismatches.append({
                    "website_code": report["website_code"],
                    "salable_qty": report["salable_qty"],
                })

    in_sync = not drifted_websites and not qty_mismatches
    return {
        "inSync": in_sync,
        "driftedWebsites": drifted_websites,
        "qtyMismatches": qty_mismatches,
    }


def run():
    if not SKU or not WEBSITE_CODES:
        log.error("SKU and WEBSITE_CODES must both be set.")
        return 2

    token = get_token()
    assigned_website_ids = product_website_ids(token, SKU)
    log.info("SKU %s is assigned to website ids: %s", SKU, assigned_website_ids)

    reports = []
    for website_code in WEBSITE_CODES:
        stock_id = resolve_stock_id_for_website(token, website_code)
        qty = salable_qty(token, SKU, stock_id)
        reports.append({"website_code": website_code, "stock_id": stock_id, "salable_qty": qty})

    source_items = source_items_for_sku(token, SKU)
    source_qty_sum = sum(item.get("quantity", 0) for item in source_items)

    verdict = detect_stock_desync(reports, EXPECTED_SHARED_STOCK_ID)

    report = {
        "sku": SKU,
        "expected_shared_stock_id": EXPECTED_SHARED_STOCK_ID,
        "websites": reports,
        "source_items_qty_sum": source_qty_sum,
        "in_sync": verdict["inSync"],
        "drifted_websites": verdict["driftedWebsites"],
        "qty_mismatches": verdict["qtyMismatches"],
    }
    print(json.dumps(report, indent=2))

    if verdict["inSync"]:
        log.info("Done. Websites are in sync for SKU %s.", SKU)
        return 0

    log.warning(
        "Done. SKU %s is NOT in sync. Drifted websites: %s. Qty mismatches: %s. %s",
        SKU, verdict["driftedWebsites"], verdict["qtyMismatches"],
        "dry run, nothing written" if DRY_RUN else "report only, no write ever attempted",
    )
    return 1


if __name__ == "__main__":
    sys.exit(run())
detect-stock-desync.js
/**
 * Detect Magento 2 or Adobe Commerce websites whose shared stock has drifted
 * out of sync.
 *
 * MSI computes salable quantity per stock_id, source item quantity assigned to
 * that stock minus every outstanding reservation keyed by SKU and stock_id. Two
 * websites only share one pool of stock when their sales channels both resolve
 * to the same stock_id. "Not synced" oversell almost always means that mapping
 * drifted, a website's sales channel was reassigned to a different stock, or
 * some legacy or third party code wrote quantity directly into the deprecated
 * cataloginventory_stock_item table instead of creating a reservation, bypassing
 * the reservation ledger entirely. This script resolves each website's actual
 * stock_id, reads its salable quantity for a SKU, and flags any drift or
 * mismatch. It never reassigns a stock or writes product data: that stays a
 * deliberate admin decision made in Stores, Configuration, Sales Channels, plus
 * a CLI reindex and manual reservation reconciliation for legacy write paths.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/shared-stock-not-synced-across-websites/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const SKU = process.env.SKU || "";
const EXPECTED_SHARED_STOCK_ID = Number(process.env.EXPECTED_SHARED_STOCK_ID || 1);
const WEBSITE_CODES = (process.env.WEBSITE_CODES || "").split(",").map((s) => s.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function detectStockDesync(websiteStockReports, expectedSharedStockId) {
  const driftedWebsites = [];
  const qtyMismatches = [];

  for (const report of websiteStockReports) {
    if (report.stock_id !== expectedSharedStockId) {
      driftedWebsites.push(report.website_code);
    }
  }

  const inSyncGroup = websiteStockReports.filter((r) => r.stock_id === expectedSharedStockId);
  if (inSyncGroup.length) {
    const baseQty = inSyncGroup[0].salable_qty;
    for (const report of inSyncGroup) {
      if (report.salable_qty !== baseQty) {
        qtyMismatches.push({ website_code: report.website_code, salable_qty: report.salable_qty });
      }
    }
  }

  const inSync = driftedWebsites.length === 0 && qtyMismatches.length === 0;
  return { inSync, driftedWebsites, qtyMismatches };
}

async function getToken() {
  if (ADMIN_TOKEN) return ADMIN_TOKEN;
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function productWebsiteIds(token, sku) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}/websites`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function resolveStockIdForWebsite(token, websiteCode) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/stock-resolver/website/${encodeURIComponent(websiteCode)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function salableQty(token, sku, stockId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/${encodeURIComponent(sku)}/${stockId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function sourceItemsForSku(token, sku) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/source-items?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  return body.items || [];
}

export async function run() {
  if (!SKU || !WEBSITE_CODES.length) {
    console.error("SKU and WEBSITE_CODES must both be set.");
    return 2;
  }

  const token = await getToken();
  const assignedWebsiteIds = await productWebsiteIds(token, SKU);
  console.log(`SKU ${SKU} is assigned to website ids: ${JSON.stringify(assignedWebsiteIds)}`);

  const reports = [];
  for (const websiteCode of WEBSITE_CODES) {
    const stockId = await resolveStockIdForWebsite(token, websiteCode);
    const qty = await salableQty(token, SKU, stockId);
    reports.push({ website_code: websiteCode, stock_id: stockId, salable_qty: qty });
  }

  const sourceItems = await sourceItemsForSku(token, SKU);
  const sourceQtySum = sourceItems.reduce((sum, item) => sum + (item.quantity || 0), 0);

  const verdict = detectStockDesync(reports, EXPECTED_SHARED_STOCK_ID);

  const report = {
    sku: SKU,
    expected_shared_stock_id: EXPECTED_SHARED_STOCK_ID,
    websites: reports,
    source_items_qty_sum: sourceQtySum,
    in_sync: verdict.inSync,
    drifted_websites: verdict.driftedWebsites,
    qty_mismatches: verdict.qtyMismatches,
  };
  console.log(JSON.stringify(report, null, 2));

  if (verdict.inSync) {
    console.log(`Done. Websites are in sync for SKU ${SKU}.`);
    return 0;
  }

  console.warn(
    `Done. SKU ${SKU} is NOT in sync. Drifted websites: ${JSON.stringify(verdict.driftedWebsites)}. ` +
    `Qty mismatches: ${JSON.stringify(verdict.qtyMismatches)}. ` +
    `${DRY_RUN ? "dry run, nothing written" : "report only, no write ever attempted"}.`
  );
  return 1;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run()
    .then((code) => process.exit(code))
    .catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The desync rule is the part most worth testing, because it decides whether a website pair is treated as shared stock or not. Since detect_stock_desync and detectStockDesync are pure, the tests need no network and no Magento instance. They just feed in plain website reports and check the verdict, covering a fully in sync group, a drifted stock_id, and a quantity mismatch within a shared stock_id.

test_shared_stock_desync.py
from detect_stock_desync import detect_stock_desync


def report(**over):
    base = {"website_code": "base", "stock_id": 1, "salable_qty": 42}
    base.update(over)
    return base


def test_in_sync_when_stock_ids_and_qty_match():
    reports = [report(), report(website_code="eu_website")]
    result = detect_stock_desync(reports, expected_shared_stock_id=1)
    assert result == {"inSync": True, "driftedWebsites": [], "qtyMismatches": []}


def test_flags_drifted_website_with_wrong_stock_id():
    reports = [report(), report(website_code="eu_website", stock_id=2, salable_qty=42)]
    result = detect_stock_desync(reports, expected_shared_stock_id=1)
    assert result["inSync"] is False
    assert result["driftedWebsites"] == ["eu_website"]
    assert result["qtyMismatches"] == []


def test_flags_qty_mismatch_when_stock_ids_agree():
    reports = [report(salable_qty=42), report(website_code="eu_website", stock_id=1, salable_qty=10)]
    result = detect_stock_desync(reports, expected_shared_stock_id=1)
    assert result["inSync"] is False
    assert result["driftedWebsites"] == []
    assert result["qtyMismatches"] == [{"website_code": "eu_website", "salable_qty": 10}]


def test_flags_both_drift_and_mismatch_together():
    reports = [
        report(salable_qty=42),
        report(website_code="eu_website", stock_id=1, salable_qty=10),
        report(website_code="apac_website", stock_id=3, salable_qty=99),
    ]
    result = detect_stock_desync(reports, expected_shared_stock_id=1)
    assert result["inSync"] is False
    assert result["driftedWebsites"] == ["apac_website"]
    assert result["qtyMismatches"] == [{"website_code": "eu_website", "salable_qty": 10}]


def test_single_website_is_trivially_in_sync():
    result = detect_stock_desync([report()], expected_shared_stock_id=1)
    assert result["inSync"] is True


def test_empty_reports_is_in_sync():
    result = detect_stock_desync([], expected_shared_stock_id=1)
    assert result == {"inSync": True, "driftedWebsites": [], "qtyMismatches": []}
desync.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectStockDesync } from "./detect-stock-desync.js";

const report = (over = {}) => ({ website_code: "base", stock_id: 1, salable_qty: 42, ...over });

test("in sync when stock ids and qty match", () => {
  const reports = [report(), report({ website_code: "eu_website" })];
  const result = detectStockDesync(reports, 1);
  assert.deepEqual(result, { inSync: true, driftedWebsites: [], qtyMismatches: [] });
});

test("flags drifted website with wrong stock id", () => {
  const reports = [report(), report({ website_code: "eu_website", stock_id: 2, salable_qty: 42 })];
  const result = detectStockDesync(reports, 1);
  assert.equal(result.inSync, false);
  assert.deepEqual(result.driftedWebsites, ["eu_website"]);
  assert.deepEqual(result.qtyMismatches, []);
});

test("flags qty mismatch when stock ids agree", () => {
  const reports = [report({ salable_qty: 42 }), report({ website_code: "eu_website", stock_id: 1, salable_qty: 10 })];
  const result = detectStockDesync(reports, 1);
  assert.equal(result.inSync, false);
  assert.deepEqual(result.driftedWebsites, []);
  assert.deepEqual(result.qtyMismatches, [{ website_code: "eu_website", salable_qty: 10 }]);
});

test("flags both drift and mismatch together", () => {
  const reports = [
    report({ salable_qty: 42 }),
    report({ website_code: "eu_website", stock_id: 1, salable_qty: 10 }),
    report({ website_code: "apac_website", stock_id: 3, salable_qty: 99 }),
  ];
  const result = detectStockDesync(reports, 1);
  assert.equal(result.inSync, false);
  assert.deepEqual(result.driftedWebsites, ["apac_website"]);
  assert.deepEqual(result.qtyMismatches, [{ website_code: "eu_website", salable_qty: 10 }]);
});

test("single website is trivially in sync", () => {
  const result = detectStockDesync([report()], 1);
  assert.equal(result.inSync, true);
});

test("empty reports is in sync", () => {
  const result = detectStockDesync([], 1);
  assert.deepEqual(result, { inSync: true, driftedWebsites: [], qtyMismatches: [] });
});

Case studies

Stock reassignment

A EU launch that quietly split the warehouse

An apparel brand added a second website for the EU market and, while wiring up its sales channel, an admin picked the wrong stock in the dropdown during a rushed setup. Both websites still looked identical in the catalog, same products, same prices, but the EU site was actually reading a stock that had never been reindexed with fresh source item quantities.

Running the script against the two website codes immediately showed the EU website resolving to stock_id 2 instead of the expected stock_id 1. The fix was a five minute correction in Stores, Configuration, Sales Channels, something nobody would have found by staring at product data, since the product data was never the problem.

Legacy write path

A warehouse integration that never left MSI

A hardware retailer kept an older warehouse management integration that predated their MSI migration. It still wrote quantity updates straight into cataloginventory_stock_item, and both websites correctly shared stock_id 1, so the drift diagnosis initially looked like a dead end.

The script's quantity mismatch check caught it anyway. Both websites shared the same stock_id, yet get-product-salable-quantity returned different numbers for the same SKU within seconds of each other, which only happens when something outside the reservation system is moving the needle. That pointed the team straight at the legacy integration instead of a fruitless search through sales channel settings.

What good looks like

After running this on a schedule, a shared stock desync stops being something you discover from a customer complaint about a sold out oversell. You get a structured report naming exactly which website drifted onto the wrong stock_id, or which websites report different salable quantities despite sharing one, plus the source items sum feeding that stock, and a non-zero exit code your monitoring can catch. The correction, reassigning the sales channel or chasing down a legacy write path, still belongs to a human, but nobody has to find the gap the hard way first.

FAQ

Why are two Magento websites that should share stock selling different quantities of the same SKU?

MSI computes salable quantity per stock_id, not per website. Two websites only share one pool of stock when their sales channels both resolve to the same stock_id. If that mapping was changed in Stores, Configuration, Sales Channels, or through the inventory stocks API, one website can silently resolve to a different stock_id than the other, so each keeps its own separate reservation ledger and the two totals drift apart even though a merchant assumed they were shared.

Can code that writes directly to cataloginventory_stock_item cause this too?

Yes. That table is the deprecated single source inventory model. MSI reservations are append only rows keyed by SKU and stock_id, and any legacy or third party code that writes quantity straight into cataloginventory_stock_item instead of creating a reservation never touches that ledger. The salable quantity MSI reports then disagrees with the number that legacy code just wrote, and the drift looks identical to a stock mapping problem from the outside.

Can a script fix a shared stock desync automatically?

No, not safely. Reassigning a website to a different stock is a deliberate admin decision made in Stores, Configuration, Sales Channels, and repairing quantities that were written directly into the legacy table requires a CLI reindex and manual reservation reconciliation that the REST API cannot perform atomically. A script can only detect and report the drift so a human can correct the sales channel mapping or investigate the legacy write path.

Related field notes

Citations

On the problem:

  1. Magento 2 MSI not updating the salable Quantity Properly, magento/inventory issue 2727. github.com/magento/inventory/issues/2727
  2. Salable Quantity Calculation and Mechanism of Reservations, magento/inventory Wiki. github.com/magento/inventory/wiki Salable-Quantity-Calculation-and-Mechanism-of-Reservations
  3. Stocks to Sales Channel Mapping, magento/inventory Wiki. github.com/magento/inventory/wiki Stocks-to-Sales-Channel-Mapping

On the solution:

  1. Inventory Management API Reference, Commerce PHP Extensions. developer.adobe.com commerce/php/development/components/web-api/inventory-management
  2. Inventory Management REST endpoints, Adobe Commerce. developer.adobe.com commerce/webapi/rest/inventory
  3. Reservations, Commerce PHP Extensions. developer.adobe.com commerce/php/development/framework/inventory-management/reservations

Stuck on a tricky one?

If you have a problem in Magento indexing, cron, MSI stock, or order grid 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 your shared stock drift?

If this saved you a confusing oversell or a mystery quantity mismatch, 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 Magento field notes