Diagnostic Storefront and API access
Medusa storefront shows no products
The catalog is full of products in the admin, but the storefront renders an empty grid. No error in the console, no failed request, just nothing. In Medusa v2 this is almost always a publishable API key that exists and is valid, but is not linked to any sales channel, so it authenticates fine and matches zero products. Here is why Medusa answers with an empty list instead of an error, and a small script that finds the broken keys and safely repairs the one case that is safe to repair.
Every /store/* request is scoped by the x-publishable-api-key header, and that key's scope is entirely defined by which sales channels are linked to it. A key with zero linked sales channels is valid, not rejected, so /store/products returns HTTP 200 with an empty array instead of erroring. Run a small Python or Node.js script that lists every publishable key via /admin/api-keys, classifies each one with a pure decision function, and for keys with no linked sales channels, links the default sales channel with POST /admin/api-keys/{id}/sales-channels. Every other broken state is reported only. Full code, tests, and a dry run guard are below.
The problem in plain words
A Medusa v2 storefront never talks to the database directly. Every product list, every cart, every checkout call goes through the Store API, and every one of those calls must carry an x-publishable-api-key header. Medusa uses that key to decide which sales channel the request belongs to, and it only returns products that are assigned to that sales channel.
That is where the trap is. A publishable key is created independently of any sales channel. You can generate one, copy it into the storefront .env, and it will authenticate perfectly, because the key itself is real. But if nobody ran the "Add sales channels" step, the key is linked to nothing. Medusa does not see that as broken. It sees a valid key with an empty scope, so every /store/products call for that key matches zero products and comes back as a normal, successful, empty list.
Why it happens
Because the failure is a silent empty list rather than an error, the same root cause shows up in a few different disguises:
- The storefront
.envwas generated from a template with a placeholder or wrongpk_...value that was never swapped for the store's real key. - A publishable key was created through the admin UI or the Admin API, but the person creating it skipped the "Add sales channels" step, leaving it linked to nothing.
- The products are real and published, but they are assigned only to a different sales channel than the one the storefront's key is scoped to, for example a B2B channel instead of the default storefront channel.
- A sales channel was linked correctly at first, then later disabled or unlinked during cleanup, and nobody connected that change to the storefront going empty.
Medusa's own docs describe two related but different failures here: a missing key entirely, which does raise a "Publishable API key required" error, and a present key with no sales channel, which does not raise anything. See the citations at the end for the exact pages and the linked GitHub issue where a store owner hit this same silent case.
Medusa only rejects a request when the header is missing or the key itself is invalid. A key that exists and is not revoked but has no linked sales channel is treated as a normal, authenticated request that happens to match nothing. So the fix is not "check if the key works." It is "check what the key is scoped to." That is a different query, against a different part of the Admin API, and it is the one most people never think to run.
The fix, as a flow
We list every publishable key and read back its linked sales channels. A pure decision function classifies each key into one of five states. Only one of those states, a key with zero linked sales channels, gets an automatic and additive repair: link it to the default sales channel. Every other broken state, a revoked key, a key whose channels are all disabled, or a key whose channels have no products assigned, is reported so a human makes the call, because those are merchant business decisions, not wiring bugs.
Build it step by step
Set up admin credentials and dry run
The script authenticates as an admin user, not with the publishable key itself, because reading and editing a key's sales channels is an admin-only operation. Keep the backend URL, admin email, and admin password in environment variables, and leave DRY_RUN on until you have reviewed the plan.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, change to false to write
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, change to false to write
Authenticate and list every publishable key
Exchange the admin email and password for a JWT at POST /auth/user/emailpass, then call GET /admin/api-keys filtered to type=publishable, expanding *sales_channels so each key comes back with its linked channels. Page through with limit and offset so the script scales past 100 keys.
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 list_publishable_keys(token):
keys, offset, limit = [], 0, 100
while True:
r = requests.get(
f"{BACKEND_URL}/admin/api-keys",
headers={"Authorization": f"Bearer {token}"},
params={
"type": "publishable", "limit": limit, "offset": offset,
"fields": "id,title,token,redacted,revoked_at,*sales_channels",
},
timeout=30,
)
r.raise_for_status()
data = r.json()
keys.extend(data["api_keys"])
offset += limit
if offset >= data["count"]:
return keys
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
await sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function listPublishableKeys() {
const keys = [];
let offset = 0;
const limit = 100;
while (true) {
const { api_keys, count } = await sdk.admin.apiKey.list({
type: "publishable",
limit,
offset,
fields: "id,title,token,redacted,revoked_at,*sales_channels",
});
keys.push(...api_keys);
offset += limit;
if (offset >= count) return keys;
}
}
Count products per sales channel
To tell a truly empty channel from one that simply has no products yet, ask /admin/products filtered by sales_channel_id[] for a count, using limit=1 since we only need the total, not the rows. Build a map of sales channel id to product count once, and reuse it across every key.
def product_count_for_sales_channel(token, sales_channel_id):
r = requests.get(
f"{BACKEND_URL}/admin/products",
headers={"Authorization": f"Bearer {token}"},
params={"sales_channel_id[]": sales_channel_id, "limit": 1, "fields": "id"},
timeout=30,
)
r.raise_for_status()
return r.json()["count"]
async function productCountForSalesChannel(salesChannelId) {
const { count } = await sdk.admin.product.list({
sales_channel_id: [salesChannelId],
limit: 1,
fields: "id",
});
return count;
}
Decide, with one pure function
Keep the classification in its own function that takes a key record and the product count map and returns a status and an action. It checks the states in order of priority: revoked first, since a security decision always wins, then an empty channel list, then all channels disabled, then all channels empty of products. Only no_sales_channels gets an automatic action, because linking a key to a channel adds a link, it never removes merchant configuration.
def decide_publishable_key_fix(key, product_count_by_sales_channel):
if key.get("revoked_at"):
return {"status": "revoked", "action": "flag"}
channels = key.get("sales_channels") or []
if len(channels) == 0:
return {"status": "no_sales_channels", "action": "link_default_channel"}
if all(ch.get("is_disabled") is True for ch in channels):
return {"status": "channels_disabled", "action": "flag"}
if all(product_count_by_sales_channel.get(ch["id"], 0) == 0 for ch in channels):
return {"status": "channels_empty", "action": "flag"}
return {"status": "ok", "action": "none"}
export function decidePublishableKeyFix(key, productCountBySalesChannel) {
if (key.revoked_at) {
return { status: "revoked", action: "flag" };
}
const channels = key.sales_channels || [];
if (channels.length === 0) {
return { status: "no_sales_channels", action: "link_default_channel" };
}
if (channels.every((ch) => ch.is_disabled === true)) {
return { status: "channels_disabled", action: "flag" };
}
if (channels.every((ch) => (productCountBySalesChannel[ch.id] || 0) === 0)) {
return { status: "channels_empty", action: "flag" };
}
return { status: "ok", action: "none" };
}
Link the default sales channel
When a key is classified no_sales_channels, resolve the default sales channel id with GET /admin/sales-channels?name=Default%20Sales%20Channel, or let the operator pass one in directly. Then call POST /admin/api-keys/{id}/sales-channels with {"add": [{"id": sc_id}]}. This is the current v2 Admin API route; some third-party mirrors still show the pre-rename /admin/publishable-api-keys/{id}/sales-channels/batch path, so check the live OpenAPI spec for your Medusa version before relying on either.
def default_sales_channel_id(token):
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels",
headers={"Authorization": f"Bearer {token}"},
params={"name": "Default Sales Channel", "limit": 1},
timeout=30,
)
r.raise_for_status()
channels = r.json()["sales_channels"]
if not channels:
raise RuntimeError("No 'Default Sales Channel' found. Pass SALES_CHANNEL_ID explicitly.")
return channels[0]["id"]
def link_default_sales_channel(token, key_id, sales_channel_id):
r = requests.post(
f"{BACKEND_URL}/admin/api-keys/{key_id}/sales-channels",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"add": [{"id": sales_channel_id}]},
timeout=30,
)
r.raise_for_status()
return r.json()
async function defaultSalesChannelId() {
const { sales_channels } = await sdk.admin.salesChannel.list({
name: "Default Sales Channel",
limit: 1,
});
if (!sales_channels.length) {
throw new Error("No 'Default Sales Channel' found. Set SALES_CHANNEL_ID explicitly.");
}
return sales_channels[0].id;
}
async function linkDefaultSalesChannel(keyId, salesChannelId) {
return sdk.admin.apiKey.batchSalesChannels(keyId, {
add: [salesChannelId],
});
}
Wire it together with a dry run guard
The loop authenticates once, lists every publishable key, builds the product count map, then classifies and, only for the safe case, links the default channel. With DRY_RUN on, it prints the planned POST body and stops there. With it off, it makes the call and re-fetches the key with GET /admin/api-keys/{id}?fields=id,*sales_channels to confirm the link took.
Always start with DRY_RUN=true. The script only auto-fixes the no_sales_channels case, because that is purely additive. A revoked key, disabled channels, or channels with no products are merchant decisions, so those are only ever reported through the flag output, never changed automatically.
The full code
Here is the complete script in one file for each language. It authenticates as an admin, lists every publishable key with its sales channels, counts products per channel, classifies each key with the pure function, and links the default sales channel only for keys that have none. It is safe to run again and again, because a key that already has a channel is left untouched.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa publishable API keys that match no products and repair the safe case.
In Medusa v2, every /store/* request is scoped by the x-publishable-api-key header,
and that key's scope is defined entirely by which sales channels are linked to it.
A key with zero linked sales channels is valid but matches no products, so
/store/products silently returns an empty array instead of erroring. This lists
every publishable key, classifies it with a pure decision function, and for the
"no_sales_channels" case, links it to the default sales channel. Every other
classification (revoked, channels_disabled, channels_empty) is reported only,
never auto-fixed. Run once, or on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_publishable_key_sales_channel")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SALES_CHANNEL_ID_OVERRIDE = os.environ.get("SALES_CHANNEL_ID", "").strip() or None
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 admin_post(token, path, body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
def decide_publishable_key_fix(key, product_count_by_sales_channel):
"""Pure decision function. No I/O.
key: {"id": str, "revoked_at": str | None, "sales_channels": [{"id": str, "is_disabled": bool}, ...]}
product_count_by_sales_channel: {sales_channel_id: count}
Returns {"status": str, "action": str}.
"""
if key.get("revoked_at"):
return {"status": "revoked", "action": "flag"}
channels = key.get("sales_channels") or []
if len(channels) == 0:
return {"status": "no_sales_channels", "action": "link_default_channel"}
if all(ch.get("is_disabled") is True for ch in channels):
return {"status": "channels_disabled", "action": "flag"}
if all(product_count_by_sales_channel.get(ch["id"], 0) == 0 for ch in channels):
return {"status": "channels_empty", "action": "flag"}
return {"status": "ok", "action": "none"}
def list_publishable_keys(token):
keys = []
offset = 0
limit = 100
while True:
data = admin_get(
token,
"/admin/api-keys",
{
"type": "publishable",
"limit": limit,
"offset": offset,
"fields": "id,title,token,redacted,revoked_at,*sales_channels",
},
)
keys.extend(data["api_keys"])
offset += limit
if offset >= data["count"]:
return keys
def product_count_for_sales_channel(token, sales_channel_id):
data = admin_get(
token,
"/admin/products",
{"sales_channel_id[]": sales_channel_id, "limit": 1, "fields": "id"},
)
return data["count"]
def default_sales_channel_id(token):
if SALES_CHANNEL_ID_OVERRIDE:
return SALES_CHANNEL_ID_OVERRIDE
data = admin_get(token, "/admin/sales-channels", {"name": "Default Sales Channel", "limit": 1})
channels = data["sales_channels"]
if not channels:
raise RuntimeError("No 'Default Sales Channel' found. Pass SALES_CHANNEL_ID explicitly.")
return channels[0]["id"]
def link_default_sales_channel(token, key_id, sales_channel_id):
return admin_post(
token,
f"/admin/api-keys/{key_id}/sales-channels",
{"add": [{"id": sales_channel_id}]},
)
def run():
token = get_admin_token()
keys = list_publishable_keys(token)
product_count_by_sales_channel = {}
for key in keys:
for ch in key.get("sales_channels") or []:
if ch["id"] not in product_count_by_sales_channel:
product_count_by_sales_channel[ch["id"]] = product_count_for_sales_channel(token, ch["id"])
fixed = 0
for key in keys:
decision = decide_publishable_key_fix(key, product_count_by_sales_channel)
log.info("Key %s (%s): status=%s action=%s", key["id"], key.get("title"), decision["status"], decision["action"])
if decision["action"] != "link_default_channel":
continue
sc_id = default_sales_channel_id(token)
log.info(
"Key %s has no sales channels. %s POST /admin/api-keys/%s/sales-channels {\"add\": [{\"id\": \"%s\"}]}",
key["id"], "Would call" if DRY_RUN else "Calling", key["id"], sc_id,
)
if not DRY_RUN:
link_default_sales_channel(token, key["id"], sc_id)
after = admin_get(token, f"/admin/api-keys/{key['id']}", {"fields": "id,*sales_channels"})
log.info("Confirmed. Key %s now has %d linked sales channel(s).", key["id"], len(after["api_key"]["sales_channels"]))
fixed += 1
log.info("Done. %d key(s) %s.", fixed, "to link" if DRY_RUN else "linked")
if __name__ == "__main__":
run()
/**
* Find Medusa publishable API keys that match no products and repair the safe case.
*
* In Medusa v2, every /store/* request is scoped by the x-publishable-api-key header,
* and that key's scope is defined entirely by which sales channels are linked to it.
* A key with zero linked sales channels is valid but matches no products, so
* /store/products silently returns an empty array instead of erroring. This lists
* every publishable key, classifies it with a pure decision function, and for the
* "no_sales_channels" case, links it to the default sales channel. Every other
* classification (revoked, channels_disabled, channels_empty) is reported only,
* never auto-fixed. Run once, or on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/storefront-shows-no-products-missing-publishable-key/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SALES_CHANNEL_ID_OVERRIDE = (process.env.SALES_CHANNEL_ID || "").trim() || null;
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, revoked_at: string|null, sales_channels: { id: string, is_disabled: boolean }[] }} key
* @param {Record<string, number>} productCountBySalesChannel
* @returns {{ status: "ok"|"revoked"|"no_sales_channels"|"channels_disabled"|"channels_empty", action: "none"|"flag"|"link_default_channel" }}
*/
export function decidePublishableKeyFix(key, productCountBySalesChannel) {
if (key.revoked_at) {
return { status: "revoked", action: "flag" };
}
const channels = key.sales_channels || [];
if (channels.length === 0) {
return { status: "no_sales_channels", action: "link_default_channel" };
}
if (channels.every((ch) => ch.is_disabled === true)) {
return { status: "channels_disabled", action: "flag" };
}
if (channels.every((ch) => (productCountBySalesChannel[ch.id] || 0) === 0)) {
return { status: "channels_empty", action: "flag" };
}
return { status: "ok", action: "none" };
}
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 adminPost(token, path, body) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Medusa ${res.status} on POST ${path}`);
return res.json();
}
async function listPublishableKeys(token) {
const keys = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/api-keys", {
type: "publishable",
limit,
offset,
fields: "id,title,token,redacted,revoked_at,*sales_channels",
});
keys.push(...data.api_keys);
offset += limit;
if (offset >= data.count) return keys;
}
}
async function productCountForSalesChannel(token, salesChannelId) {
const data = await adminGet(token, "/admin/products", {
"sales_channel_id[]": salesChannelId,
limit: 1,
fields: "id",
});
return data.count;
}
async function defaultSalesChannelId(token) {
if (SALES_CHANNEL_ID_OVERRIDE) return SALES_CHANNEL_ID_OVERRIDE;
const data = await adminGet(token, "/admin/sales-channels", {
name: "Default Sales Channel",
limit: 1,
});
if (!data.sales_channels.length) {
throw new Error("No 'Default Sales Channel' found. Set SALES_CHANNEL_ID explicitly.");
}
return data.sales_channels[0].id;
}
async function linkDefaultSalesChannel(token, keyId, salesChannelId) {
return adminPost(token, `/admin/api-keys/${keyId}/sales-channels`, {
add: [{ id: salesChannelId }],
});
}
export async function run() {
const token = await getAdminToken();
const keys = await listPublishableKeys(token);
const productCountBySalesChannel = {};
for (const key of keys) {
for (const ch of key.sales_channels || []) {
if (!(ch.id in productCountBySalesChannel)) {
productCountBySalesChannel[ch.id] = await productCountForSalesChannel(token, ch.id);
}
}
}
let fixed = 0;
for (const key of keys) {
const decision = decidePublishableKeyFix(key, productCountBySalesChannel);
console.log(`Key ${key.id} (${key.title}): status=${decision.status} action=${decision.action}`);
if (decision.action !== "link_default_channel") continue;
const scId = await defaultSalesChannelId(token);
console.log(
`Key ${key.id} has no sales channels. ${DRY_RUN ? "Would call" : "Calling"} POST /admin/api-keys/${key.id}/sales-channels {"add": [{"id": "${scId}"}]}`
);
if (!DRY_RUN) {
await linkDefaultSalesChannel(token, key.id, scId);
const after = await adminGet(token, `/admin/api-keys/${key.id}`, { fields: "id,*sales_channels" });
console.log(`Confirmed. Key ${key.id} now has ${after.api_key.sales_channels.length} linked sales channel(s).`);
}
fixed++;
}
console.log(`Done. ${fixed} key(s) ${DRY_RUN ? "to link" : "linked"}.`);
}
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 which keys get auto-repaired and which get flagged for a human. Because decide_publishable_key_fix is pure, the test needs no Medusa backend and no network. It just feeds in plain key records and a product count map, and checks the classification.
from fix_publishable_key_sales_channel import decide_publishable_key_fix
def key(**over):
base = {
"id": "pk_1",
"revoked_at": None,
"sales_channels": [{"id": "sc_1", "is_disabled": False}],
}
base.update(over)
return base
def test_revoked_key_is_flagged():
result = decide_publishable_key_fix(key(revoked_at="2026-01-01T00:00:00Z"), {"sc_1": 5})
assert result == {"status": "revoked", "action": "flag"}
def test_no_sales_channels_is_linked():
result = decide_publishable_key_fix(key(sales_channels=[]), {})
assert result == {"status": "no_sales_channels", "action": "link_default_channel"}
def test_all_channels_disabled_is_flagged():
result = decide_publishable_key_fix(
key(sales_channels=[{"id": "sc_1", "is_disabled": True}, {"id": "sc_2", "is_disabled": True}]),
{"sc_1": 5, "sc_2": 3},
)
assert result == {"status": "channels_disabled", "action": "flag"}
def test_all_channels_have_zero_products_is_flagged():
result = decide_publishable_key_fix(key(), {"sc_1": 0})
assert result == {"status": "channels_empty", "action": "flag"}
def test_healthy_key_is_ok():
result = decide_publishable_key_fix(key(), {"sc_1": 12})
assert result == {"status": "ok", "action": "none"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { decidePublishableKeyFix } from "./fix-publishable-key-sales-channel.js";
const key = (over = {}) => ({
id: "pk_1",
revoked_at: null,
sales_channels: [{ id: "sc_1", is_disabled: false }],
...over,
});
test("revoked key is flagged", () => {
const result = decidePublishableKeyFix(key({ revoked_at: "2026-01-01T00:00:00Z" }), { sc_1: 5 });
assert.deepEqual(result, { status: "revoked", action: "flag" });
});
test("no sales channels is linked", () => {
const result = decidePublishableKeyFix(key({ sales_channels: [] }), {});
assert.deepEqual(result, { status: "no_sales_channels", action: "link_default_channel" });
});
test("all channels disabled is flagged", () => {
const result = decidePublishableKeyFix(
key({ sales_channels: [{ id: "sc_1", is_disabled: true }, { id: "sc_2", is_disabled: true }] }),
{ sc_1: 5, sc_2: 3 }
);
assert.deepEqual(result, { status: "channels_disabled", action: "flag" });
});
test("all channels have zero products is flagged", () => {
const result = decidePublishableKeyFix(key(), { sc_1: 0 });
assert.deepEqual(result, { status: "channels_empty", action: "flag" });
});
test("healthy key is ok", () => {
const result = decidePublishableKeyFix(key(), { sc_1: 12 });
assert.deepEqual(result, { status: "ok", action: "none" });
});
Case studies
The launch checklist that skipped one line
A team stood up a new Next.js storefront from a starter template. The template shipped with a placeholder NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY, and whoever wired up the real backend swapped in a freshly generated key from the admin, but never ran the "Add sales channels" step on it. The storefront built and deployed cleanly. Every page loaded. The product grid was just empty, and nobody suspected the key because it was clearly a real, working key.
Running the script against the backend immediately classified that key as no_sales_channels. In dry run it printed the exact POST body it would send. The team reviewed it, turned off dry run, and the storefront had products within a minute of the real fix landing.
Products assigned to the wrong sales channel
A merchant migrating from a spreadsheet import assigned every product to a "Wholesale" sales channel that a previous developer had set as the import default, instead of the storefront's actual "Default Sales Channel." The publishable key itself was linked correctly, so this looked identical to a missing key from the storefront's point of view: valid key, still no products.
Because the script cross-checks product counts per sales channel, it classified this key as channels_empty rather than no_sales_channels, and only flagged it. That distinction mattered. Auto-linking a different channel would not have fixed anything, since the real problem was where the products lived, and that is a catalog decision for the merchant to make.
After this runs, every publishable key in the store has a known state instead of a mystery. Keys with no sales channel get linked automatically and safely. Keys that are revoked, disabled, or pointed at an empty catalog get surfaced clearly instead of silently returning empty product lists forever. The storefront stops looking broken for a reason nobody could see in the browser.
FAQ
Why does my Medusa storefront show no products even though the catalog has them?
Every /store/* request in Medusa v2 is scoped by the x-publishable-api-key header, and that key only matches products in the sales channels linked to it. If the key has zero linked sales channels, Medusa treats the request as valid and returns HTTP 200 with an empty products array instead of an error, so it looks like the store has no products when the real issue is the key's scope.
Is a key with no sales channels an error in Medusa?
No. Medusa only rejects a request when the x-publishable-api-key header is missing or the key itself is invalid, which returns a 400 or 401 style error. A key that exists, is not revoked, but has no linked sales channels is accepted and simply matches nothing, so the response is a normal 200 with an empty list.
Is it safe to automatically fix every broken publishable key?
Only the no_sales_channels case is safe to auto-fix, because linking a key to the default sales channel is additive and does not change any existing merchant configuration. A revoked key, a key whose channels are all disabled, or a key whose channels have no products assigned are all business decisions, so the script only reports those and leaves the fix to a human.
Related field notes
Citations
On the problem:
- Medusa Documentation: Publishable API key required error. docs.medusajs.com/resources/troubleshooting/storefront-missing-pak
- Medusa Documentation: Publishable API needs to have a sales channel error. docs.medusajs.com/resources/troubleshooting/storefront-pak-sc
- GitHub medusajs/medusa Issue #13878: storefront home page section not showing products. github.com/medusajs/medusa/issues/13878
On the solution:
- Medusa Documentation: Use a Publishable API Key in the Storefront. docs.medusajs.com/resources/storefront-development/publishable-api-keys
- Medusa Documentation: Publishable API Keys with Sales Channels. docs.medusajs.com/resources/commerce-modules/sales-channel/publishable-api-keys
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, or workflows 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 bring your products back?
If this saved you a confusing afternoon of staring at an empty storefront, 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