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.

Python and Node.js Medusa Admin API Safe by default (dry run)
The short answer

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.

Storefront sends x-publishable-api-key Look up link ApiKey to SalesChannel zero channels linked Empty scope sales_channel_ids = [] 0 products returned
The key authenticates fine. It is the missing ApiKey-to-SalesChannel link that leaves the storefront query with nothing to scope against.

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:

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.

The key insight

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.

Resolve the key GET /admin/api-keys Read sales_channels id, is_disabled Count active links not disabled, not revoked Zero active, default known? yes no, flag it add sales channel key now scoped
The script only links a key when it has zero active sales channel links and the target channel is unambiguous. Everything else is flagged for a human.

Build it step by step

1

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.

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 DRY_RUN="true"   # start safe, change to false to write
setup (shell)
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
2

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.

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 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"]
step2.js
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;
}
3

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.

step3.py
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")]
step3.js
async function getEnabledSalesChannels() {
  const { sales_channels } = await sdk.admin.salesChannel.list({
    fields: "id,name,is_disabled",
  });
  return sales_channels.filter((c) => !c.is_disabled);
}
4

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.

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

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.

apply.py
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
apply.js
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);
}
6

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.

Run it safe

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.

link_publishable_key.py
"""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()
link-publishable-key.js
/**
 * 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.

test_decide.py
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",
    }
decide.test.js
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

New store launch

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.

Channel cleanup

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.

What good looks like

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:

  1. Medusa Documentation: Publishable API needs to have a sales channel error. docs.medusajs.com/resources/troubleshooting/storefront-pak-sc
  2. GitHub Discussion: Sales channel won't be attached to the API key. github.com/medusajs/medusa/discussions/4823
  3. GitHub Issue: Configure Publishable key with sales channel. github.com/medusajs/medusa/issues/10885

On the solution:

  1. Medusa Documentation: Publishable API Keys with Sales Channels. docs.medusajs.com/resources/commerce-modules/sales-channel/publishable-api-keys
  2. Medusa Documentation: Links between API Key Module and Other Modules. docs.medusajs.com/resources/commerce-modules/api-key/links-to-other-modules
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes