Diagnostic Storefront and API access
Medusa publishable key not linked to a sales channel
The key looks fine. It has the right prefix, it is not revoked, and it is sitting in the header of every storefront request. But /store/products keeps coming back empty, while the same catalog shows up just fine from the Admin API. Nine times out of ten, the key was never linked to a sales channel, or the channel it was linked to got disabled later. Here is why that link matters so much and a small script that finds keys stuck in that state.
A Medusa v2 publishable key has no built-in visibility into products. It only works through a many-to-many link between the ApiKey and SalesChannel data models, and every /store/* route derives sales_channel_ids from that link to scope its queries. If the key was created but never linked to a channel, or its channel was later disabled, it resolves to zero sales channels and the storefront sees nothing. Run a small Python or Node.js script that resolves the key, checks its linked sales channels, and either flags the problem or links it to an explicit target channel with POST /admin/api-keys/{id}/sales-channels. Full code, tests, and a dry run guard are below.
The problem in plain words
In Medusa v2, a publishable API key, the pk_... string you put in the x-publishable-api-key header, is managed by the API Key Module. On its own, that module knows nothing about products. What actually scopes a storefront request to a catalog is a separate link: the ApiKey model is linked, many-to-many, to the SalesChannel model.
When a merchant creates a key through POST /admin/api-keys or the Admin dashboard, that link is a second, separate step. You have to call the sales-channels link route (or click the equivalent button in the dashboard) to attach the new key to one or more sales channels. Skip that step, or delete or disable the sales channel later, and the key still exists, still authenticates, and still looks completely normal. It just points at nothing.
Why it happens
Since /store/* routes read req.publishable_key_context.sales_channel_ids from that link to scope every product and cart query, an unlinked key is functionally silent rather than an error. A few common ways stores end up here:
- The key was created through
POST /admin/api-keysor a setup script, and the second call to attach it to a sales channel was simply never made. - The sales channel the key was linked to got deleted or archived during a store cleanup, quietly dropping the key back to zero channels.
- The sales channel is still there but was disabled with
is_disabled: true, for example while a new storefront was being staged, and nobody re-enabled it or moved the key. - A seed script or migration created keys and sales channels separately, assuming a default link that Medusa does not create automatically.
This is a common source of confusion because nothing in the response looks broken. GET /store/products with the key returns a normal 200 with count: 0, not an authentication error, so it is easy to spend time checking prices, inventory, and publishing status before anyone checks the key's sales channel link. See the citations at the end for the exact docs and threads.
A publishable key is not a permission, it is a scope. Fixing it is not "link every key to every channel." It is "link only the keys that currently resolve to zero active sales channels, and only to a sales channel someone actually chose." That is why the safe pattern flags the ambiguous case and only writes when a target channel is explicit or there is exactly one unambiguous default to use.
The fix, as a flow
We do not touch products, prices, or inventory. We add a check that resolves the key, reads its linked sales channels, and decides: if it already has an active link, leave it; if it has zero active links and there is one clear default channel, link it; otherwise, flag it for a human to pick the right channel.
Build it step by step
Get an admin token
Exchange an admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
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
Resolve the key and its linked sales channels
Ask the Admin API for the publishable key with its sales channels expanded. The fields query param prefixes a relation with * to include it, so *sales_channels brings back each linked channel's id and is_disabled flag alongside the key.
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 get_publishable_keys(token):
r = requests.get(
f"{BACKEND_URL}/admin/api-keys",
headers={"Authorization": f"Bearer {token}"},
params={"type": "publishable", "fields": "id,token,title,revoked_at,*sales_channels"},
timeout=30,
)
r.raise_for_status()
return r.json()["api_keys"]
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
return sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function getPublishableKeys() {
const { api_keys } = await sdk.admin.apiKey.list({
type: "publishable",
fields: "id,token,title,revoked_at,*sales_channels",
});
return api_keys;
}
Confirm at least one enabled channel exists
Before offering a default to link against, check that the store actually has an enabled sales channel to attach. If every channel in the store is disabled, there is nothing safe to link to, and the right move is to flag every affected key.
def get_enabled_sales_channels(token):
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,is_disabled"},
timeout=30,
)
r.raise_for_status()
channels = r.json()["sales_channels"]
return [c for c in channels if not c.get("is_disabled")]
async function getEnabledSalesChannels() {
const { sales_channels } = await sdk.admin.salesChannel.list({
fields: "id,name,is_disabled",
});
return sales_channels.filter((c) => !c.is_disabled);
}
Decide, with one pure function
Keep the decision in its own function that takes the key and a default sales channel id and returns an action. It is strict on purpose. A revoked key is left alone. A key with at least one active link is left alone. A key with zero active links only gets a link action when a default channel id is known, otherwise it is only flagged.
def decide_api_key_repair(api_key, default_sales_channel_id):
if api_key.get("revoked_at"):
return {"action": "none", "reason": "key revoked"}
active_links = [sc for sc in api_key.get("sales_channels") or [] if not sc.get("is_disabled")]
if len(active_links) > 0:
return {"action": "none", "reason": "already linked to an active sales channel"}
if default_sales_channel_id is None:
return {"action": "flag", "reason": "no sales channel linked and no unambiguous default to link"}
return {
"action": "link",
"reason": "key has zero active sales-channel links",
"sales_channel_id_to_add": default_sales_channel_id,
}
export function decideApiKeyRepair(apiKey, defaultSalesChannelId) {
if (apiKey.revoked_at) {
return { action: "none", reason: "key revoked" };
}
const activeLinks = (apiKey.sales_channels || []).filter((sc) => !sc.is_disabled);
if (activeLinks.length > 0) {
return { action: "none", reason: "already linked to an active sales channel" };
}
if (defaultSalesChannelId === null || defaultSalesChannelId === undefined) {
return { action: "flag", reason: "no sales channel linked and no unambiguous default to link" };
}
return {
action: "link",
reason: "key has zero active sales-channel links",
salesChannelIdToAdd: defaultSalesChannelId,
};
}
Link the key the way the dashboard would
When the decision is to link, call POST /admin/api-keys/{api_key_id}/sales-channels with the channel to add. Then re-fetch the key with *sales_channels and confirm the target channel is now present before you report success. Never guess a channel; only act when one is explicit.
def link_sales_channel(token, api_key_id, sales_channel_id):
r = requests.post(
f"{BACKEND_URL}/admin/api-keys/{api_key_id}/sales-channels",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"add": [sales_channel_id]},
timeout=30,
)
r.raise_for_status()
return r.json()
def confirm_linked(token, api_key_id, sales_channel_id):
r = requests.get(
f"{BACKEND_URL}/admin/api-keys/{api_key_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*sales_channels"},
timeout=30,
)
r.raise_for_status()
linked_ids = {sc["id"] for sc in r.json()["api_key"]["sales_channels"]}
return sales_channel_id in linked_ids
async function linkSalesChannel(apiKeyId, salesChannelId) {
return sdk.admin.apiKey.batchSalesChannels(apiKeyId, {
add: [salesChannelId],
});
}
async function confirmLinked(apiKeyId, salesChannelId) {
const { api_key } = await sdk.admin.apiKey.retrieve(apiKeyId, {
fields: "id,*sales_channels",
});
const linkedIds = new Set(api_key.sales_channels.map((sc) => sc.id));
return linkedIds.has(salesChannelId);
}
Wire it together with a dry run guard
The run loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs which key it would link and to which channel. Read the output, agree with it, then switch it off to let it write. It is safe to run again and again, since a key that is already linked is simply left alone.
Always start with DRY_RUN=true, and never let the script pick a sales channel on its own when more than one enabled channel exists. Set SALES_CHANNEL_ID explicitly, or only rely on the automatic default when the store truly has one obvious channel.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and confirms the link after writing it, so it never claims success it did not verify.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa publishable keys with zero active sales-channel links and link them, safely.
A publishable key only scopes /store/* requests through a link to one or more sales
channels. If that link is missing, or every linked channel is disabled, the key
resolves to zero sales channels and the storefront sees no products. This lists
every publishable key, decides what to do with a pure function, and only writes
when a target sales channel is explicit or there is exactly one unambiguous
default channel. Every other case is reported only. 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("link_publishable_key")
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 get_publishable_keys(token):
r = requests.get(
f"{BACKEND_URL}/admin/api-keys",
headers={"Authorization": f"Bearer {token}"},
params={"type": "publishable", "fields": "id,token,title,revoked_at,*sales_channels"},
timeout=30,
)
r.raise_for_status()
return r.json()["api_keys"]
def get_enabled_sales_channels(token):
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,is_disabled"},
timeout=30,
)
r.raise_for_status()
channels = r.json()["sales_channels"]
return [c for c in channels if not c.get("is_disabled")]
def decide_api_key_repair(api_key, default_sales_channel_id):
"""Pure decision function. No I/O.
api_key: {"id": str, "revoked_at": str | None, "sales_channels": [{"id": str, "is_disabled": bool}, ...]}
default_sales_channel_id: str | None
Returns {"action": "none" | "flag" | "link", "reason": str, "sales_channel_id_to_add"?: str}.
"""
if api_key.get("revoked_at"):
return {"action": "none", "reason": "key revoked"}
active_links = [sc for sc in api_key.get("sales_channels") or [] if not sc.get("is_disabled")]
if len(active_links) > 0:
return {"action": "none", "reason": "already linked to an active sales channel"}
if default_sales_channel_id is None:
return {"action": "flag", "reason": "no sales channel linked and no unambiguous default to link"}
return {
"action": "link",
"reason": "key has zero active sales-channel links",
"sales_channel_id_to_add": default_sales_channel_id,
}
def link_sales_channel(token, api_key_id, sales_channel_id):
r = requests.post(
f"{BACKEND_URL}/admin/api-keys/{api_key_id}/sales-channels",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"add": [sales_channel_id]},
timeout=30,
)
r.raise_for_status()
return r.json()
def confirm_linked(token, api_key_id, sales_channel_id):
r = requests.get(
f"{BACKEND_URL}/admin/api-keys/{api_key_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*sales_channels"},
timeout=30,
)
r.raise_for_status()
linked_ids = {sc["id"] for sc in r.json()["api_key"]["sales_channels"]}
return sales_channel_id in linked_ids
def default_sales_channel_id(token):
if SALES_CHANNEL_ID_OVERRIDE:
return SALES_CHANNEL_ID_OVERRIDE
enabled = get_enabled_sales_channels(token)
if len(enabled) == 1:
return enabled[0]["id"]
return None
def run():
token = get_admin_token()
keys = get_publishable_keys(token)
default_sc_id = default_sales_channel_id(token)
linked = 0
flagged = 0
for api_key in keys:
decision = decide_api_key_repair(api_key, default_sc_id)
log.info("Key %s (%s): action=%s reason=%s", api_key["id"], api_key.get("title"), decision["action"], decision["reason"])
if decision["action"] == "flag":
flagged += 1
continue
if decision["action"] != "link":
continue
sc_id = decision["sales_channel_id_to_add"]
log.info(
"%s api key %s to sales channel %s",
"Would link" if DRY_RUN else "Linking", api_key["id"], sc_id,
)
if not DRY_RUN:
link_sales_channel(token, api_key["id"], sc_id)
if not confirm_linked(token, api_key["id"], sc_id):
raise RuntimeError(f"Link did not take effect for key {api_key['id']}")
log.info("Confirmed. Key %s is now linked to sales channel %s.", api_key["id"], sc_id)
linked += 1
log.info("Done. %d key(s) %s, %d key(s) flagged for review.", linked, "to link" if DRY_RUN else "linked", flagged)
if __name__ == "__main__":
run()
/**
* Find Medusa publishable keys with zero active sales-channel links and link them, safely.
*
* A publishable key only scopes /store/* requests through a link to one or more sales
* channels. If that link is missing, or every linked channel is disabled, the key
* resolves to zero sales channels and the storefront sees no products. This lists
* every publishable key, decides what to do with a pure function, and only writes
* when a target sales channel is explicit or there is exactly one unambiguous
* default channel. Every other case is reported only. Run once, or on a schedule.
*
* Guide: https://www.allanninal.dev/medusa/publishable-key-not-linked-to-a-sales-channel/
*/
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;
export function decideApiKeyRepair(apiKey, defaultSalesChannelId) {
if (apiKey.revoked_at) {
return { action: "none", reason: "key revoked" };
}
const activeLinks = (apiKey.sales_channels || []).filter((sc) => !sc.is_disabled);
if (activeLinks.length > 0) {
return { action: "none", reason: "already linked to an active sales channel" };
}
if (defaultSalesChannelId === null || defaultSalesChannelId === undefined) {
return { action: "flag", reason: "no sales channel linked and no unambiguous default to link" };
}
return {
action: "link",
reason: "key has zero active sales-channel links",
salesChannelIdToAdd: defaultSalesChannelId,
};
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BACKEND_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
return sdk;
}
async function getPublishableKeys(sdk) {
const { api_keys } = await sdk.admin.apiKey.list({
type: "publishable",
fields: "id,token,title,revoked_at,*sales_channels",
});
return api_keys;
}
async function getEnabledSalesChannels(sdk) {
const { sales_channels } = await sdk.admin.salesChannel.list({
fields: "id,name,is_disabled",
});
return sales_channels.filter((c) => !c.is_disabled);
}
async function defaultSalesChannelId(sdk) {
if (SALES_CHANNEL_ID_OVERRIDE) return SALES_CHANNEL_ID_OVERRIDE;
const enabled = await getEnabledSalesChannels(sdk);
return enabled.length === 1 ? enabled[0].id : null;
}
async function linkSalesChannel(sdk, apiKeyId, salesChannelId) {
return sdk.admin.apiKey.batchSalesChannels(apiKeyId, { add: [salesChannelId] });
}
async function confirmLinked(sdk, apiKeyId, salesChannelId) {
const { api_key } = await sdk.admin.apiKey.retrieve(apiKeyId, { fields: "id,*sales_channels" });
const linkedIds = new Set(api_key.sales_channels.map((sc) => sc.id));
return linkedIds.has(salesChannelId);
}
export async function run() {
const sdk = await getSdk();
const keys = await getPublishableKeys(sdk);
const defaultScId = await defaultSalesChannelId(sdk);
let linked = 0;
let flagged = 0;
for (const apiKey of keys) {
const decision = decideApiKeyRepair(apiKey, defaultScId);
console.log(`Key ${apiKey.id} (${apiKey.title}): action=${decision.action} reason=${decision.reason}`);
if (decision.action === "flag") {
flagged++;
continue;
}
if (decision.action !== "link") continue;
const scId = decision.salesChannelIdToAdd;
console.log(`${DRY_RUN ? "Would link" : "Linking"} api key ${apiKey.id} to sales channel ${scId}`);
if (!DRY_RUN) {
await linkSalesChannel(sdk, apiKey.id, scId);
const ok = await confirmLinked(sdk, apiKey.id, scId);
if (!ok) throw new Error(`Link did not take effect for key ${apiKey.id}`);
console.log(`Confirmed. Key ${apiKey.id} is now linked to sales channel ${scId}.`);
}
linked++;
}
console.log(`Done. ${linked} key(s) ${DRY_RUN ? "to link" : "linked"}, ${flagged} key(s) flagged for review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a storefront gets pointed at a new sales channel. Because we kept decide_api_key_repair pure, the test needs no network and no Medusa backend. It just feeds in plain objects and checks the answer.
from link_publishable_key import decide_api_key_repair
def api_key(**over):
base = {"id": "pk_1", "revoked_at": None, "sales_channels": []}
base.update(over)
return base
def test_revoked_key_is_left_alone():
result = decide_api_key_repair(api_key(revoked_at="2026-01-01T00:00:00Z"), "sc_default")
assert result == {"action": "none", "reason": "key revoked"}
def test_key_with_active_link_is_left_alone():
result = decide_api_key_repair(api_key(sales_channels=[{"id": "sc_1", "is_disabled": False}]), "sc_default")
assert result == {"action": "none", "reason": "already linked to an active sales channel"}
def test_key_with_only_disabled_links_and_no_default_is_flagged():
result = decide_api_key_repair(api_key(sales_channels=[{"id": "sc_1", "is_disabled": True}]), None)
assert result == {"action": "flag", "reason": "no sales channel linked and no unambiguous default to link"}
def test_key_with_zero_links_and_no_default_is_flagged():
result = decide_api_key_repair(api_key(sales_channels=[]), None)
assert result == {"action": "flag", "reason": "no sales channel linked and no unambiguous default to link"}
def test_key_with_zero_active_links_and_a_default_is_linked():
result = decide_api_key_repair(api_key(sales_channels=[{"id": "sc_1", "is_disabled": True}]), "sc_default")
assert result == {
"action": "link",
"reason": "key has zero active sales-channel links",
"sales_channel_id_to_add": "sc_default",
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideApiKeyRepair } from "./link-publishable-key.js";
const apiKey = (over = {}) => ({ id: "pk_1", revoked_at: null, sales_channels: [], ...over });
test("revoked key is left alone", () => {
const result = decideApiKeyRepair(apiKey({ revoked_at: "2026-01-01T00:00:00Z" }), "sc_default");
assert.deepEqual(result, { action: "none", reason: "key revoked" });
});
test("key with active link is left alone", () => {
const result = decideApiKeyRepair(apiKey({ sales_channels: [{ id: "sc_1", is_disabled: false }] }), "sc_default");
assert.deepEqual(result, { action: "none", reason: "already linked to an active sales channel" });
});
test("key with only disabled links and no default is flagged", () => {
const result = decideApiKeyRepair(apiKey({ sales_channels: [{ id: "sc_1", is_disabled: true }] }), null);
assert.deepEqual(result, { action: "flag", reason: "no sales channel linked and no unambiguous default to link" });
});
test("key with zero links and no default is flagged", () => {
const result = decideApiKeyRepair(apiKey({ sales_channels: [] }), null);
assert.deepEqual(result, { action: "flag", reason: "no sales channel linked and no unambiguous default to link" });
});
test("key with zero active links and a default is linked", () => {
const result = decideApiKeyRepair(apiKey({ sales_channels: [{ id: "sc_1", is_disabled: true }] }), "sc_default");
assert.deepEqual(result, {
action: "link",
reason: "key has zero active sales-channel links",
salesChannelIdToAdd: "sc_default",
});
});
Case studies
The key that was never linked in the first place
A team stood up a new Medusa store from a setup script that created an admin user, a region, and a publishable key, but the script stopped one step short of linking the key to the sales channel. The storefront deployed clean, the build passed, and the homepage rendered, just with an empty product grid.
Running the script in dry run showed one key with zero active sales-channel links and one obvious default channel in the store. Turning off dry run linked it in one call, and the storefront started listing products immediately, no redeploy needed.
The disabled channel that took the key down with it
A merchant retired an old "Pop-up" sales channel by disabling it during a cleanup, not realizing their main storefront's publishable key was linked only to that channel. Products vanished from the live site the moment the change went out, and support tickets started before anyone connected it to the cleanup.
The detection step flagged the key immediately, since its only linked channel was now disabled and there was more than one enabled channel in the store, so no default was assumed. The team picked the correct "Default Sales Channel" explicitly with SALES_CHANNEL_ID and relinked it in minutes.
After this runs, every publishable key either has a confirmed active sales channel link or is sitting in a clear flagged list with a reason attached. No key gets linked to a channel nobody chose, and a key that already works is never touched. The gap between "key exists" and "key resolves to a catalog" closes before a customer ever notices an empty storefront.
FAQ
Why does my Medusa storefront show zero products with a valid publishable key?
A publishable key has no visibility into products on its own. It only works through a link to one or more sales channels, and the storefront query is scoped to whatever sales channels that key is linked to. If the key has zero linked sales channels, or every linked channel is disabled, that scope is empty, so the Store API returns zero products even though products, prices, and inventory are otherwise fine.
Is it safe to automatically link a publishable key to a sales channel?
Not by guessing. Picking the wrong sales channel could expose the wrong catalog or the wrong prices to a storefront, so the safe default is to flag the key and only link it when a target sales channel is explicit, or when dry run is off and there is exactly one unambiguous default channel to use.
How do I check which sales channels a Medusa publishable key is linked to?
Call GET /admin/api-keys/{id} with fields=id,title,revoked_at,*sales_channels using an admin Bearer token. The sales_channels array in the response is the live link. An empty array, or an array where every channel has is_disabled true, means the key currently resolves to no usable sales channel.
Related field notes
Citations
On the problem:
- Medusa Documentation: Publishable API needs to have a sales channel error. docs.medusajs.com/resources/troubleshooting/storefront-pak-sc
- GitHub Discussion: Sales channel won't be attached to the API key. github.com/medusajs/medusa/discussions/4823
- GitHub Issue: Configure Publishable key with sales channel. github.com/medusajs/medusa/issues/10885
On the solution:
- Medusa Documentation: Publishable API Keys with Sales Channels. docs.medusajs.com/resources/commerce-modules/sales-channel/publishable-api-keys
- Medusa Documentation: Links between API Key Module and Other Modules. docs.medusajs.com/resources/commerce-modules/api-key/links-to-other-modules
- Medusa Admin User Guide: Manage Publishable API Keys. docs.medusajs.com/user-guide/settings/developer/publishable-api-keys
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 get your storefront showing products again?
If this saved you a confusing afternoon chasing a phantom empty catalog, 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