Diagnostic Stock Locations & Sales Channels
Publishable key with multiple channels reports zero stock
The admin shows real stock sitting at real stock locations. The storefront, using a publishable key scoped to more than one sales channel, insists the variant has zero available. No error, no exception, just a quantity of 0 where a number should be. In Medusa v2 this shows up when the code that resolves a key's stock locations only knows how to handle a single linked sales channel, and silently narrows or drops the filter the moment a key maps to two or more. Here is why that narrowing happens and a small diagnostic that proves it, product by product, so you know exactly which keys and variants are affected before you touch anything.
The Store API is supposed to find every stock location linked to every sales channel a publishable key is scoped to, then sum stocked_quantity minus reserved_quantity across all of them. The buggy code path, documented in medusajs/medusa#7907 and the related #12209, only handles the case where req.publishableApiKeyScopes.sales_channel_ids.length === 1. When a key is linked to more than one channel, that check fails, sales_channel_id ends up undefined or empty, the inventory-location join returns nothing, and inventory_quantity comes back 0 even though the admin API shows real stock. Run a small Python or Node.js script that compares the admin's computed available quantity against what the Store API reports for the same key and product, and flags every mismatch that only happens when the key has more than one linked channel. Full code, tests, and a dry run guard are below.
The problem in plain words
A publishable key in Medusa v2 is not just an identity. It is a scope. Every request that carries an x-publishable-api-key header gets resolved to the list of sales channels that key is linked to, and everything the Store API answers, products, prices, and stock, is filtered through that scope.
For inventory specifically, resolving "how much is available" means walking from the sales channel to the stock locations linked to it through SalesChannelLocation, then summing the inventory levels at those locations for the variant. That walk works fine when a key is scoped to exactly one sales channel. The trouble starts when a key is scoped to two or more, which is completely normal for a storefront that serves both a retail channel and a wholesale channel, or a multi-brand deployment sharing one storefront app. The code that builds the location filter was written expecting a single id, and when it gets an array of two or more, it either grabs only the first one or drops the filter altogether, and the join that should return several rows returns none.
Why it happens
Because this bug lives in how a length check gates which value gets used as a filter, the same shape of failure shows up in a few disguises:
- The original bug in medusajs/medusa#7907, where the query builder assigned a single
sales_channel_idfilter only when the key's scoped channel array had a length of exactly 1, leaving multi-channel keys with no filter at all. - The related regression in #12209, where a filterable field, the sales channel id, gets stripped out somewhere in middleware before the inventory query config ever sees it.
- A custom middleware or query config that copies this exact pattern, taking
req.publishableApiKeyScopes.sales_channel_ids[0]instead of the full array, which works fine until the day a key gets linked to a second channel. - A storefront that intentionally serves two channels through one key, for example a combined retail and wholesale frontend, which is exactly the setup most likely to trip this because it always has
length > 1.
The result in every case is the same fingerprint: the product itself is visible and correctly scoped, the price is correct, but variants.inventory_quantity reads 0 for a variant that the admin API confirms has real stock at a location linked to one of the key's channels. See the citations at the end for the exact issue threads and docs.
This is not a data problem. The stock is real, the links between sales channels and stock locations are real, and the admin side proves it every time. The defect is in how many sales channels the request-scoping code expects, one, versus how many a key can actually have, any number greater than or equal to one. So the diagnostic never touches inventory data. It only compares what the admin's own data says should be available against what the Store API says is available for the same key, and it treats a mismatch that only appears when the key has more than one channel as the fingerprint of this exact bug.
The fix, as a flow
We read each publishable key's linked sales channels from the admin, then read each of those channels' linked stock locations, to build the expected location set for that key. We read the admin's location levels for a sample of products to compute what should be available. Then we call the Store API with the real key and compare. A pure function decides, from those two numbers and the key's channel count, whether this is the bug.
Build it step by step
Set up admin credentials, the publishable key, and dry run
The script needs an admin JWT to read location levels and channel links, and it also needs a real publishable key to call the Store API the same way the storefront would. Keep the backend URL, admin email, admin password, and the key in environment variables. DRY_RUN defaults to true, and this diagnostic never performs a write in either mode, it only ever reports.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MEDUSA_PUBLISHABLE_KEY="pk_..."
export DRY_RUN="true" # this diagnostic never writes, in either mode
// Node 18+ has fetch built in, no dependencies needed
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MEDUSA_PUBLISHABLE_KEY="pk_..."
export DRY_RUN="true" // this diagnostic never writes, in either mode
Read the key's scoped sales channels and their stock locations
Authenticate at POST /auth/user/emailpass, then call GET /admin/api-keys/{id}?fields=id,*sales_channels to get the sc_... ids the key is scoped to. For each sales channel, call GET /admin/stock-locations?fields=id,name,*sales_channels and keep only the locations whose linked channels include that sales channel id. This builds the expected stock location set per channel, which the pure function later unions.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def key_sales_channel_ids(token, key_id):
r = requests.get(
f"{BACKEND_URL}/admin/api-keys/{key_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*sales_channels"},
timeout=30,
)
r.raise_for_status()
channels = r.json()["api_key"]["sales_channels"] or []
return [ch["id"] for ch in channels]
def stock_locations_for_channel(token, sales_channel_id):
r = requests.get(
f"{BACKEND_URL}/admin/stock-locations",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,*sales_channels", "limit": 200},
timeout=30,
)
r.raise_for_status()
locations = r.json()["stock_locations"]
return [
loc["id"] for loc in locations
if any(ch["id"] == sales_channel_id for ch in (loc.get("sales_channels") or []))
]
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL;
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
return (await res.json()).token;
}
async function keySalesChannelIds(token, keyId) {
const url = new URL(`${BACKEND_URL}/admin/api-keys/${keyId}`);
url.searchParams.set("fields", "id,*sales_channels");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return (body.api_key.sales_channels || []).map((ch) => ch.id);
}
async function stockLocationsForChannel(token, salesChannelId) {
const url = new URL(`${BACKEND_URL}/admin/stock-locations`);
url.searchParams.set("fields", "id,name,*sales_channels");
url.searchParams.set("limit", "200");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.stock_locations
.filter((loc) => (loc.sales_channels || []).some((ch) => ch.id === salesChannelId))
.map((loc) => loc.id);
}
Read admin-side location levels and the Store API's answer
For each product, call the Admin API for its variants' inventory items and their location_levels, with stocked_quantity and reserved_quantity. Separately, call the Store API for the same product with the real publishable key in x-publishable-api-key and read variants.inventory_quantity. These two numbers, plus the key's channel ids, are everything the decision needs.
def admin_location_levels(token, product_id):
r = requests.get(
f"{BACKEND_URL}/admin/products/{product_id}",
headers={"Authorization": f"Bearer {token}"},
params={
"fields": "id,*variants.inventory_items.inventory.location_levels.stocked_quantity,"
"*variants.inventory_items.inventory.location_levels.reserved_quantity",
},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
def store_inventory_quantity(publishable_key, product_id):
r = requests.get(
f"{BACKEND_URL}/store/products/{product_id}",
headers={"x-publishable-api-key": publishable_key},
params={"fields": "id,title,*variants.inventory_quantity"},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
async function adminLocationLevels(token, productId) {
const url = new URL(`${BACKEND_URL}/admin/products/${productId}`);
url.searchParams.set(
"fields",
"id,*variants.inventory_items.inventory.location_levels.stocked_quantity," +
"*variants.inventory_items.inventory.location_levels.reserved_quantity"
);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return (await res.json()).product;
}
async function storeInventoryQuantity(publishableKey, productId) {
const url = new URL(`${BACKEND_URL}/store/products/${productId}`);
url.searchParams.set("fields", "id,title,*variants.inventory_quantity");
const res = await fetch(url, { headers: { "x-publishable-api-key": publishableKey } });
if (!res.ok) throw new Error(`Medusa store ${res.status}`);
return (await res.json()).product;
}
Decide, with one pure function
Keep the decision in its own function that takes the key's scoped channel ids, the admin's location levels keyed by location id, the expected stock location ids per channel, and what the Store API reported, and returns whether this is the bug. It unions the expected locations across every channel the key is scoped to, sums max(stocked_quantity - reserved_quantity, 0) over that union, and only calls it a bug when the key has more than one channel, the admin side proves real stock exists, and the store reports exactly 0.
def diagnose_zero_stock_mismatch(
publishable_key_scope_sales_channel_ids,
admin_location_levels_by_location_id,
expected_stock_location_ids_by_channel,
store_reported_inventory_quantity,
):
expected_location_ids = set()
for channel_id in publishable_key_scope_sales_channel_ids:
expected_location_ids.update(expected_stock_location_ids_by_channel.get(channel_id, []))
expected_available = 0
for location_id in expected_location_ids:
level = admin_location_levels_by_location_id.get(location_id)
if not level:
continue
expected_available += max(level["stockedQuantity"] - level["reservedQuantity"], 0)
is_multi_channel = len(publishable_key_scope_sales_channel_ids) > 1
if is_multi_channel and expected_available > 0 and store_reported_inventory_quantity == 0:
return {"isBug": True, "expectedAvailable": expected_available, "reason": "multi-channel-key-zero-stock"}
if expected_available <= 0:
return {"isBug": False, "expectedAvailable": expected_available, "reason": "genuinely-out-of-stock"}
return {"isBug": False, "expectedAvailable": expected_available, "reason": "ok"}
export function diagnoseZeroStockMismatch(
publishableKeyScopeSalesChannelIds,
adminLocationLevelsByLocationId,
expectedStockLocationIdsByChannel,
storeReportedInventoryQuantity
) {
const expectedLocationIds = new Set();
for (const channelId of publishableKeyScopeSalesChannelIds) {
for (const locId of expectedStockLocationIdsByChannel[channelId] || []) {
expectedLocationIds.add(locId);
}
}
let expectedAvailable = 0;
for (const locationId of expectedLocationIds) {
const level = adminLocationLevelsByLocationId[locationId];
if (!level) continue;
expectedAvailable += Math.max(level.stockedQuantity - level.reservedQuantity, 0);
}
const isMultiChannel = publishableKeyScopeSalesChannelIds.length > 1;
if (isMultiChannel && expectedAvailable > 0 && storeReportedInventoryQuantity === 0) {
return { isBug: true, expectedAvailable, reason: "multi-channel-key-zero-stock" };
}
if (expectedAvailable <= 0) {
return { isBug: false, expectedAvailable, reason: "genuinely-out-of-stock" };
}
return { isBug: false, expectedAvailable, reason: "ok" };
}
Wire it together and only ever report
The loop authenticates once, resolves the key's channels and expected locations, then walks a sample of products, pulling admin location levels and the store's answer for each. It builds the two lookup maps the pure function needs, calls it, and logs every mismatch. Nothing here ever calls a write endpoint, in dry run or not, because there is no safe write on the integration side for a bug in Medusa's own request-scoping logic.
This diagnostic performs no write operations against Medusa in either DRY_RUN mode. It only reads from the Admin API and the Store API and reports mismatches. Treat every flagged row as a signal to upgrade Medusa, patch the middleware that builds sales_channel_id, or split the storefront into one key per channel as a stopgap.
The full code
Here is the complete script in one file for each language. It authenticates as an admin, resolves a publishable key's scoped sales channels and their stock locations, reads admin-side inventory levels and the Store API's reported quantity for a sample of products, classifies each variant with the pure function, and prints a report. It is read-only end to end.
"""Detect Medusa publishable keys whose multi-channel scope makes variants read zero stock.
In Medusa v2, the Store API is supposed to resolve a variant's available inventory
by unioning the stock locations linked to every sales channel a publishable key is
scoped to, then summing stocked_quantity minus reserved_quantity across those
locations. A known bug (medusajs/medusa#7907, and the related sales_channel_id
stripping regression in #12209) only handles a key scoped to exactly one sales
channel. When a key is scoped to more than one, the location filter can be
silently narrowed to a single channel or dropped entirely, so the join returns
no rows and inventory_quantity is computed as 0 even though the admin API shows
real stock at the linked locations.
This script never writes anything, in DRY_RUN or not, because the defect lives in
Medusa core's request-scoping logic (or a custom middleware reproducing it), not
in the store's data. It only reads the admin's location levels and the Store
API's reported quantity for a sample of products under a real publishable key,
classifies each variant with a pure decision function, and reports every mismatch
whose fingerprint matches this bug.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diagnose_multi_channel_zero_stock")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
PUBLISHABLE_KEY = os.environ["MEDUSA_PUBLISHABLE_KEY"]
PUBLISHABLE_KEY_ID = os.environ.get("MEDUSA_PUBLISHABLE_KEY_ID", "").strip() or None
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true" # no write path exists either way
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def key_sales_channel_ids(token, key_id):
data = admin_get(token, f"/admin/api-keys/{key_id}", {"fields": "id,*sales_channels"})
channels = data["api_key"]["sales_channels"] or []
return [ch["id"] for ch in channels]
def stock_locations_by_channel(token, sales_channel_ids):
data = admin_get(token, "/admin/stock-locations", {"fields": "id,name,*sales_channels", "limit": 200})
locations = data["stock_locations"]
by_channel = {sc_id: [] for sc_id in sales_channel_ids}
for loc in locations:
for ch in loc.get("sales_channels") or []:
if ch["id"] in by_channel:
by_channel[ch["id"]].append(loc["id"])
return by_channel
def admin_location_levels_by_location_id(token, product_id):
data = admin_get(
token,
f"/admin/products/{product_id}",
{
"fields": "id,*variants.inventory_items.inventory.location_levels.stocked_quantity,"
"*variants.inventory_items.inventory.location_levels.reserved_quantity",
},
)
product = data["product"]
levels_by_variant = {}
for variant in product.get("variants") or []:
by_location = {}
for item in variant.get("inventory_items") or []:
inventory = item.get("inventory") or {}
for lvl in inventory.get("location_levels") or []:
by_location[lvl["location_id"]] = {
"stockedQuantity": lvl["stocked_quantity"],
"reservedQuantity": lvl["reserved_quantity"],
}
levels_by_variant[variant["id"]] = by_location
return product, levels_by_variant
def store_inventory_quantities(publishable_key, product_id):
r = requests.get(
f"{BACKEND_URL}/store/products/{product_id}",
headers={"x-publishable-api-key": publishable_key},
params={"fields": "id,title,*variants.inventory_quantity"},
timeout=30,
)
r.raise_for_status()
product = r.json()["product"]
return {v["id"]: v.get("inventory_quantity") for v in product.get("variants") or []}
def diagnose_zero_stock_mismatch(
publishable_key_scope_sales_channel_ids,
admin_location_levels_by_location_id,
expected_stock_location_ids_by_channel,
store_reported_inventory_quantity,
):
"""Pure decision function. No I/O.
publishable_key_scope_sales_channel_ids: [str, ...]
admin_location_levels_by_location_id: {location_id: {"stockedQuantity": int, "reservedQuantity": int}}
expected_stock_location_ids_by_channel: {sales_channel_id: [location_id, ...]}
store_reported_inventory_quantity: int
Returns {"isBug": bool, "expectedAvailable": int, "reason": str}.
"""
expected_location_ids = set()
for channel_id in publishable_key_scope_sales_channel_ids:
expected_location_ids.update(expected_stock_location_ids_by_channel.get(channel_id, []))
expected_available = 0
for location_id in expected_location_ids:
level = admin_location_levels_by_location_id.get(location_id)
if not level:
continue
expected_available += max(level["stockedQuantity"] - level["reservedQuantity"], 0)
is_multi_channel = len(publishable_key_scope_sales_channel_ids) > 1
if is_multi_channel and expected_available > 0 and store_reported_inventory_quantity == 0:
return {"isBug": True, "expectedAvailable": expected_available, "reason": "multi-channel-key-zero-stock"}
if expected_available <= 0:
return {"isBug": False, "expectedAvailable": expected_available, "reason": "genuinely-out-of-stock"}
return {"isBug": False, "expectedAvailable": expected_available, "reason": "ok"}
def sample_product_ids(token, limit=25):
data = admin_get(token, "/admin/products", {"limit": limit, "fields": "id"})
return [p["id"] for p in data["products"]]
def run():
token = get_admin_token()
if not PUBLISHABLE_KEY_ID:
raise RuntimeError("Set MEDUSA_PUBLISHABLE_KEY_ID to the api key's admin id (pk_...) to resolve its scope.")
channel_ids = key_sales_channel_ids(token, PUBLISHABLE_KEY_ID)
expected_locations_by_channel = stock_locations_by_channel(token, channel_ids)
log.info("Key %s is scoped to %d sales channel(s).", PUBLISHABLE_KEY_ID, len(channel_ids))
mismatches = 0
for product_id in sample_product_ids(token):
product, levels_by_variant = admin_location_levels_by_location_id(token, product_id)
store_quantities = store_inventory_quantities(PUBLISHABLE_KEY, product_id)
for variant in product.get("variants") or []:
variant_id = variant["id"]
store_qty = store_quantities.get(variant_id)
if store_qty is None:
continue
decision = diagnose_zero_stock_mismatch(
channel_ids,
levels_by_variant.get(variant_id, {}),
expected_locations_by_channel,
store_qty,
)
if decision["isBug"]:
mismatches += 1
log.warning(
"MISMATCH product=%s variant=%s key=%s channels=%d admin_expected=%d store_reported=%s reason=%s",
product_id, variant_id, PUBLISHABLE_KEY_ID, len(channel_ids),
decision["expectedAvailable"], store_qty, decision["reason"],
)
log.info("Done. %d mismatch(es) found. No write operations were performed (DRY_RUN=%s).", mismatches, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Detect Medusa publishable keys whose multi-channel scope makes variants read zero stock.
*
* In Medusa v2, the Store API is supposed to resolve a variant's available inventory
* by unioning the stock locations linked to every sales channel a publishable key is
* scoped to, then summing stocked_quantity minus reserved_quantity across those
* locations. A known bug (medusajs/medusa#7907, and the related sales_channel_id
* stripping regression in #12209) only handles a key scoped to exactly one sales
* channel. When a key is scoped to more than one, the location filter can be
* silently narrowed to a single channel or dropped entirely, so the join returns
* no rows and inventory_quantity is computed as 0 even though the admin API shows
* real stock at the linked locations.
*
* This script never writes anything, in DRY_RUN or not, because the defect lives in
* Medusa core's request-scoping logic (or a custom middleware reproducing it), not
* in the store's data. It only reads the admin's location levels and the Store
* API's reported quantity for a sample of products under a real publishable key,
* classifies each variant with a pure decision function, and reports every mismatch
* whose fingerprint matches this bug.
*
* Guide: https://www.allanninal.dev/medusa/multi-channel-key-zero-stock/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "pk_dummy";
const PUBLISHABLE_KEY_ID = (process.env.MEDUSA_PUBLISHABLE_KEY_ID || "").trim() || null;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true"; // no write path exists either way
/**
* Pure decision function. No I/O.
*
* @param {string[]} publishableKeyScopeSalesChannelIds
* @param {Record<string, {stockedQuantity: number, reservedQuantity: number}>} adminLocationLevelsByLocationId
* @param {Record<string, string[]>} expectedStockLocationIdsByChannel
* @param {number} storeReportedInventoryQuantity
* @returns {{isBug: boolean, expectedAvailable: number, reason: string}}
*/
export function diagnoseZeroStockMismatch(
publishableKeyScopeSalesChannelIds,
adminLocationLevelsByLocationId,
expectedStockLocationIdsByChannel,
storeReportedInventoryQuantity
) {
const expectedLocationIds = new Set();
for (const channelId of publishableKeyScopeSalesChannelIds) {
for (const locId of expectedStockLocationIdsByChannel[channelId] || []) {
expectedLocationIds.add(locId);
}
}
let expectedAvailable = 0;
for (const locationId of expectedLocationIds) {
const level = adminLocationLevelsByLocationId[locationId];
if (!level) continue;
expectedAvailable += Math.max(level.stockedQuantity - level.reservedQuantity, 0);
}
const isMultiChannel = publishableKeyScopeSalesChannelIds.length > 1;
if (isMultiChannel && expectedAvailable > 0 && storeReportedInventoryQuantity === 0) {
return { isBug: true, expectedAvailable, reason: "multi-channel-key-zero-stock" };
}
if (expectedAvailable <= 0) {
return { isBug: false, expectedAvailable, reason: "genuinely-out-of-stock" };
}
return { isBug: false, expectedAvailable, reason: "ok" };
}
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
return res.json();
}
async function keySalesChannelIds(token, keyId) {
const data = await adminGet(token, `/admin/api-keys/${keyId}`, { fields: "id,*sales_channels" });
return (data.api_key.sales_channels || []).map((ch) => ch.id);
}
async function stockLocationsByChannel(token, salesChannelIds) {
const data = await adminGet(token, "/admin/stock-locations", { fields: "id,name,*sales_channels", limit: 200 });
const byChannel = {};
for (const scId of salesChannelIds) byChannel[scId] = [];
for (const loc of data.stock_locations) {
for (const ch of loc.sales_channels || []) {
if (ch.id in byChannel) byChannel[ch.id].push(loc.id);
}
}
return byChannel;
}
async function adminLocationLevelsByLocationId(token, productId) {
const data = await adminGet(token, `/admin/products/${productId}`, {
fields:
"id,*variants.inventory_items.inventory.location_levels.stocked_quantity," +
"*variants.inventory_items.inventory.location_levels.reserved_quantity",
});
const product = data.product;
const levelsByVariant = {};
for (const variant of product.variants || []) {
const byLocation = {};
for (const item of variant.inventory_items || []) {
const inventory = item.inventory || {};
for (const lvl of inventory.location_levels || []) {
byLocation[lvl.location_id] = {
stockedQuantity: lvl.stocked_quantity,
reservedQuantity: lvl.reserved_quantity,
};
}
}
levelsByVariant[variant.id] = byLocation;
}
return { product, levelsByVariant };
}
async function storeInventoryQuantities(publishableKey, productId) {
const url = new URL(`${BACKEND_URL}/store/products/${productId}`);
url.searchParams.set("fields", "id,title,*variants.inventory_quantity");
const res = await fetch(url, { headers: { "x-publishable-api-key": publishableKey } });
if (!res.ok) throw new Error(`Medusa store ${res.status}`);
const body = await res.json();
const map = {};
for (const v of body.product.variants || []) map[v.id] = v.inventory_quantity;
return map;
}
async function sampleProductIds(token, limit = 25) {
const data = await adminGet(token, "/admin/products", { limit, fields: "id" });
return data.products.map((p) => p.id);
}
export async function run() {
const token = await getAdminToken();
if (!PUBLISHABLE_KEY_ID) {
throw new Error("Set MEDUSA_PUBLISHABLE_KEY_ID to the api key's admin id (pk_...) to resolve its scope.");
}
const channelIds = await keySalesChannelIds(token, PUBLISHABLE_KEY_ID);
const expectedLocationsByChannel = await stockLocationsByChannel(token, channelIds);
console.log(`Key ${PUBLISHABLE_KEY_ID} is scoped to ${channelIds.length} sales channel(s).`);
let mismatches = 0;
for (const productId of await sampleProductIds(token)) {
const { product, levelsByVariant } = await adminLocationLevelsByLocationId(token, productId);
const storeQuantities = await storeInventoryQuantities(PUBLISHABLE_KEY, productId);
for (const variant of product.variants || []) {
const variantId = variant.id;
const storeQty = storeQuantities[variantId];
if (storeQty === undefined) continue;
const decision = diagnoseZeroStockMismatch(
channelIds,
levelsByVariant[variantId] || {},
expectedLocationsByChannel,
storeQty
);
if (decision.isBug) {
mismatches++;
console.warn(
`MISMATCH product=${productId} variant=${variantId} key=${PUBLISHABLE_KEY_ID} channels=${channelIds.length} admin_expected=${decision.expectedAvailable} store_reported=${storeQty} reason=${decision.reason}`
);
}
}
}
console.log(`Done. ${mismatches} mismatch(es) found. No write operations were performed (DRY_RUN=${DRY_RUN}).`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether a mismatch is this specific bug, a genuinely empty variant, or a healthy answer. Because diagnose_zero_stock_mismatch is pure, the test needs no Medusa backend and no network. It just feeds in plain maps and arrays and checks the classification for a single-channel key, a healthy multi-channel key, and the buggy multi-channel case.
from diagnose_multi_channel_zero_stock import diagnose_zero_stock_mismatch
LEVELS = {
"sloc_1": {"stockedQuantity": 20, "reservedQuantity": 5},
"sloc_2": {"stockedQuantity": 10, "reservedQuantity": 0},
}
LOCATIONS_BY_CHANNEL = {
"sc_1": ["sloc_1"],
"sc_2": ["sloc_2"],
}
def test_single_channel_ok_is_not_a_bug():
result = diagnose_zero_stock_mismatch(["sc_1"], LEVELS, LOCATIONS_BY_CHANNEL, 15)
assert result == {"isBug": False, "expectedAvailable": 15, "reason": "ok"}
def test_multi_channel_healthy_is_not_a_bug():
result = diagnose_zero_stock_mismatch(["sc_1", "sc_2"], LEVELS, LOCATIONS_BY_CHANNEL, 25)
assert result == {"isBug": False, "expectedAvailable": 25, "reason": "ok"}
def test_multi_channel_zero_stock_is_the_bug():
result = diagnose_zero_stock_mismatch(["sc_1", "sc_2"], LEVELS, LOCATIONS_BY_CHANNEL, 0)
assert result == {"isBug": True, "expectedAvailable": 25, "reason": "multi-channel-key-zero-stock"}
def test_single_channel_zero_stock_is_not_flagged_as_the_bug():
# length == 1, so this is not the multi-channel fingerprint even if store reports 0
result = diagnose_zero_stock_mismatch(["sc_1"], LEVELS, LOCATIONS_BY_CHANNEL, 0)
assert result["isBug"] is False
def test_genuinely_out_of_stock_across_all_channels():
empty_levels = {
"sloc_1": {"stockedQuantity": 0, "reservedQuantity": 0},
"sloc_2": {"stockedQuantity": 3, "reservedQuantity": 3},
}
result = diagnose_zero_stock_mismatch(["sc_1", "sc_2"], empty_levels, LOCATIONS_BY_CHANNEL, 0)
assert result == {"isBug": False, "expectedAvailable": 0, "reason": "genuinely-out-of-stock"}
def test_reserved_never_pushes_a_location_negative():
over_reserved = {"sloc_1": {"stockedQuantity": 2, "reservedQuantity": 9}}
result = diagnose_zero_stock_mismatch(["sc_1"], over_reserved, {"sc_1": ["sloc_1"]}, 0)
assert result["expectedAvailable"] == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseZeroStockMismatch } from "./diagnose-multi-channel-zero-stock.js";
const LEVELS = {
sloc_1: { stockedQuantity: 20, reservedQuantity: 5 },
sloc_2: { stockedQuantity: 10, reservedQuantity: 0 },
};
const LOCATIONS_BY_CHANNEL = {
sc_1: ["sloc_1"],
sc_2: ["sloc_2"],
};
test("single channel ok is not a bug", () => {
const result = diagnoseZeroStockMismatch(["sc_1"], LEVELS, LOCATIONS_BY_CHANNEL, 15);
assert.deepEqual(result, { isBug: false, expectedAvailable: 15, reason: "ok" });
});
test("multi channel healthy is not a bug", () => {
const result = diagnoseZeroStockMismatch(["sc_1", "sc_2"], LEVELS, LOCATIONS_BY_CHANNEL, 25);
assert.deepEqual(result, { isBug: false, expectedAvailable: 25, reason: "ok" });
});
test("multi channel zero stock is the bug", () => {
const result = diagnoseZeroStockMismatch(["sc_1", "sc_2"], LEVELS, LOCATIONS_BY_CHANNEL, 0);
assert.deepEqual(result, { isBug: true, expectedAvailable: 25, reason: "multi-channel-key-zero-stock" });
});
test("single channel zero stock is not flagged as the bug", () => {
const result = diagnoseZeroStockMismatch(["sc_1"], LEVELS, LOCATIONS_BY_CHANNEL, 0);
assert.equal(result.isBug, false);
});
test("genuinely out of stock across all channels", () => {
const emptyLevels = {
sloc_1: { stockedQuantity: 0, reservedQuantity: 0 },
sloc_2: { stockedQuantity: 3, reservedQuantity: 3 },
};
const result = diagnoseZeroStockMismatch(["sc_1", "sc_2"], emptyLevels, LOCATIONS_BY_CHANNEL, 0);
assert.deepEqual(result, { isBug: false, expectedAvailable: 0, reason: "genuinely-out-of-stock" });
});
test("reserved never pushes a location negative", () => {
const overReserved = { sloc_1: { stockedQuantity: 2, reservedQuantity: 9 } };
const result = diagnoseZeroStockMismatch(["sc_1"], overReserved, { sc_1: ["sloc_1"] }, 0);
assert.equal(result.expectedAvailable, 0);
});
Case studies
The combined storefront that "ran out" overnight
A furniture brand ran one Next.js storefront serving both a retail sales channel and a wholesale sales channel through a single publishable key, scoped to both. One morning half the catalog showed sold out, with real pallets sitting in the warehouse. Support assumed a sync job had failed, and spent a day cross-checking the wrong system.
Running the diagnostic against a handful of the affected products showed the exact fingerprint: the key had two linked sales channels, the admin's location levels summed to real, positive stock, and the Store API reported 0 for every one of them. Nothing was wrong with the warehouse data. The team tracked it to a custom query config copying the pattern from #7907, patched it to always pass the full sales_channel_ids array, and the "sold out" catalog came back within the hour.
The new brand channel that broke the old one
A group added a second brand as a new sales channel and, to save time, linked the existing storefront's publishable key to it instead of issuing a new key. The moment that link went live, the original brand's inventory numbers on the Store API dropped to 0 across the board, even though nothing about that brand's stock had changed.
The diagnostic isolated it immediately: the key now had length(sales_channel_ids) === 2, which is exactly the condition the buggy check fails on. As a stopgap while waiting on a core fix, the team split the two brands back into separate publishable keys, one per sales channel, and both storefronts started reporting correct stock again the same day.
After running this diagnostic, you know exactly which publishable keys, products, and variants are affected, with the admin's computed available quantity right next to what the Store API reported. Nothing was written or guessed. From there the fix is a version upgrade, a middleware patch that always passes the full sales_channel_ids array, or a temporary split into one key per channel, whichever fits your deployment, made with real numbers instead of a hunch.
FAQ
Why does a Medusa publishable key linked to multiple sales channels show zero stock?
The Store API is supposed to resolve available inventory by unioning the stock locations linked to every sales channel the key is scoped to. A known bug pattern only handles the case where the key maps to exactly one sales_channel_id. When a key maps to more than one, the code can narrow that filter down to a single id or drop it entirely, the inventory-location join then returns no rows, and inventory_quantity is computed as 0 even though the admin shows real stock at those locations.
Is this the same bug as a publishable key with no sales channel at all?
No. A key with zero linked sales channels is a different, well known failure that returns an empty product list. This one only appears when a key has more than one linked sales channel, the key still returns the product, but the computed inventory_quantity for its variants comes back 0 while the admin API shows stocked_quantity minus reserved_quantity greater than 0 at the locations linked to those channels.
Can I safely auto-fix a multi-channel key that reports zero stock?
No, not from the storefront or integration side. The defect lives in how the request-scoping logic builds the sales_channel_id filter from the key's scopes, which is either core Medusa behavior tracked upstream or a custom middleware reproducing the same shape of bug. The safe move is to detect and report every mismatch, then upgrade to a patched Medusa version, fix the middleware to always pass the full sales_channel_ids array, or split the storefront into one publishable key per sales channel as a stopgap.
Related field notes
Citations
On the problem:
- GitHub medusajs/medusa Issue #7907: Inventory_quantity returns 0 when using publishable key linked with multiple sales_channel. github.com/medusajs/medusa/issues/7907
- GitHub medusajs/medusa Issue #12209: Filterable Field, Sales Channel Id, is deleted in middleware. github.com/medusajs/medusa/issues/12209
- Medusa Documentation: Publishable API Keys with Sales Channels. docs.medusajs.com/resources/commerce-modules/sales-channel/publishable-api-keys
On the solution:
- Medusa Documentation: Product Variant Inventory. docs.medusajs.com/resources/commerce-modules/product/variant-inventory
- Medusa Documentation: Retrieve Product Variant's Inventory in Storefront. docs.medusajs.com/resources/storefront-development/products/inventory
- Medusa Documentation: Links between Stock Location Module and Other Modules. docs.medusajs.com/resources/commerce-modules/stock-location/links-to-other-modules
Stuck on a tricky one?
If you have a problem in Medusa storefront access, inventory, stock locations, or sales channels 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 explain your zero stock?
If this saved you a day of chasing a warehouse sync job that was never broken, 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