Skip to content

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.

Python and Node.js Medusa Admin and Store API Safe by default (dry run)
Cardboard boxes on a floor
Photo by MAK on Unsplash
The short answer

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.

Publishable key scoped to 2 channels sales_channel_ids.length is 2, check expects 1 sales_channel_id undefined or empty Join returns no rows inventory_quantity is computed as 0, while admin location levels show real stocked_quantity.
The key is valid and the product still shows up. Only the computed inventory quantity goes to zero, because the location filter silently narrowed to nothing.

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 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.

The key insight

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.

List key + channels GET /admin/api-keys Resolve stock locations per linked sales channel Sum admin levels stocked minus reserved Store API agrees? yes, ok no, and channels > 1 Flag as bug fingerprint
The diagnostic never writes anything. It only proves, per key and product, whether the mismatch matches the shape of the multi-channel zero-stock bug.

Build it step by step

1

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.

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

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.

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

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.

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

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.

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

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
diagnose_multi_channel_zero_stock.py
"""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()
diagnose-multi-channel-zero-stock.js
/**
 * 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.

test_multichannel_zero_stock.py
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
diagnose-multi-channel-zero-stock.test.js
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

Retail plus wholesale

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.

Multi-brand rollout

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.

What good looks like

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:

  1. GitHub medusajs/medusa Issue #7907: Inventory_quantity returns 0 when using publishable key linked with multiple sales_channel. github.com/medusajs/medusa/issues/7907
  2. GitHub medusajs/medusa Issue #12209: Filterable Field, Sales Channel Id, is deleted in middleware. github.com/medusajs/medusa/issues/12209
  3. Medusa Documentation: Publishable API Keys with Sales Channels. docs.medusajs.com/resources/commerce-modules/sales-channel/publishable-api-keys

On the solution:

  1. Medusa Documentation: Product Variant Inventory. docs.medusajs.com/resources/commerce-modules/product/variant-inventory
  2. Medusa Documentation: Retrieve Product Variant's Inventory in Storefront. docs.medusajs.com/resources/storefront-development/products/inventory
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes