Diagnostic Inventory and stock
Medusa sales channel not linked to a stock location
Someone created a new sales channel, maybe for a marketplace app, a headless storefront, or a seed script, and every product through it is suddenly unpurchasable. The inventory items look fine. The location levels look fine. But carts fail, backorders error out, and the message that eventually surfaces reads something like "Sales channel xxx is not associated with any stock location." Here is why that link matters so much and a small script that finds channels stuck in that state.
In Medusa v2, inventory availability is scoped by a stored many-to-many module link between the Sales Channel module and the Stock Location module, not by the product or variant alone. Reservations, location-scoped inventory levels, and cart or checkout availability checks all resolve "which locations serve this channel" through that link. If a sales channel was created but the "manage sales channel stock locations" step was never run, it has zero associated stock locations, and every product becomes effectively unpurchasable through it. Run a small Python or Node.js script that lists sales channels, checks their linked stock locations, and either flags the problem or links an explicit target stock location with POST /admin/stock-locations/{stock_location_id}/sales-channels. Full code, tests, and a dry run guard are below.
The problem in plain words
In Medusa v2, a product does not carry its own availability. Availability comes from inventory items with location levels, and those location levels only count toward a given storefront if the sales channel making the request is linked to the stock location that holds the stock. That link, SalesChannelLocation, lives in its own table between the Sales Channel module and the Stock Location module.
When a merchant creates a new sales channel, through the Admin dashboard, a marketplace integration, a headless storefront setup, or a seed script, linking it to a stock location is a separate step. Skip that step, and the channel exists, authenticates fine, and can be attached to a publishable key, but it has no stock location of its own. Every reservation, every backorder check, and every cart availability lookup that goes through that channel comes up empty, even though the inventory items themselves have perfectly valid stock sitting at some other location.
Why it happens
Since reservations, location-scoped inventory levels, and cart or checkout availability checks all resolve "which locations serve this channel" through the SalesChannelLocation link, an unlinked channel is functionally silent until someone tries to buy through it. A few common ways stores end up here:
- A new sales channel was created for a marketplace app or a second storefront, and the "manage sales channel stock locations" link step was simply never called.
- A seed script created sales channels and stock locations in separate steps, assuming Medusa would link a default automatically, which it does not.
- A stock location that used to serve the channel was removed during a warehouse consolidation, quietly dropping the channel back to zero linked locations.
- A headless storefront was pointed at a freshly created channel before the operations team finished setting up its fulfillment locations.
This is a common source of confusion because the failure does not show up until checkout. Products list fine, prices look fine, and the inventory item clearly has stock, so it is easy to spend time checking variant prices and publishing status before anyone checks the channel's stock location link. The error that eventually surfaces, "Sales channel xxx is not associated with any stock location," is the clearest signal, and it is exactly what medusa#10694 documents when a variant with backorder is added to a cart. See the citations at the end for the exact issues and docs.
A sales channel without a stock location link is not a pricing problem or an inventory problem, it is a scope problem. Fixing it is not "link every channel to every location." It is "link only the channels that currently resolve to zero stock locations, and only to a location someone actually chose." That is why the safe pattern flags the ambiguous case and only writes when a target location is explicit or there is exactly one unambiguous default to use.
The fix, as a flow
We do not touch products, prices, or existing inventory levels. We add a check that lists sales channels, reads their linked stock locations, and decides: if a channel already has at least one linked location, leave it; if it has zero and there is one clear default location, link it; otherwise, flag it for a human to pick the right location.
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
List sales channels with their linked stock locations
Ask the Admin API for sales channels with their stock locations expanded. The fields query param prefixes a relation with * to include it, so *stock_locations brings back each linked location alongside the channel, resolving the SalesChannel-to-StockLocation module link. Page through with count, offset, and limit.
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_sales_channels(token):
channels = []
offset = 0
limit = 100
while True:
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,*stock_locations", "limit": limit, "offset": offset},
timeout=30,
)
r.raise_for_status()
body = r.json()
channels.extend(body["sales_channels"])
offset += limit
if offset >= body["count"]:
return channels
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 getSalesChannels() {
const channels = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.salesChannel.list({
fields: "id,name,*stock_locations",
limit,
offset,
});
channels.push(...body.sales_channels);
offset += limit;
if (offset >= body.count) return channels;
}
}
Confirm at least one stock location exists
Before offering a default to link against, check that the store actually has a stock location to attach. This also rules out a separate root cause: if no stock locations exist at all in the store, there is nothing safe to link to, and the right move is to flag every affected channel.
def get_stock_locations(token):
r = requests.get(
f"{BACKEND_URL}/admin/stock-locations",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,*sales_channels"},
timeout=30,
)
r.raise_for_status()
return r.json()["stock_locations"]
async function getStockLocations() {
const { stock_locations } = await sdk.admin.stockLocation.list({
fields: "id,name,*sales_channels",
});
return stock_locations;
}
Decide, with one pure function
Keep the decision in its own function that takes the list of sales channels and the list of available stock locations and returns a plan for each channel. It only auto-suggests a stock location when a default is given explicitly or exactly one location exists in the whole store, otherwise it leaves the suggestion null and forces a human decision.
def plan_stock_location_links(sales_channels, available_locations, default_location_id=None):
plans = []
for sc in sales_channels:
needs_link = len(sc.get("stock_locations") or []) == 0
suggested_location_id = None
if needs_link:
if default_location_id:
suggested_location_id = default_location_id
elif len(available_locations) == 1:
suggested_location_id = available_locations[0]["id"]
plans.append({
"sales_channel_id": sc["id"],
"sales_channel_name": sc.get("name"),
"needs_link": needs_link,
"suggested_location_id": suggested_location_id,
})
return plans
export function planStockLocationLinks(salesChannels, availableLocations, defaultLocationId) {
return salesChannels.map((sc) => {
const needsLink = (sc.stock_locations || []).length === 0;
let suggestedLocationId = null;
if (needsLink) {
if (defaultLocationId) {
suggestedLocationId = defaultLocationId;
} else if (availableLocations.length === 1) {
suggestedLocationId = availableLocations[0].id;
}
}
return {
sales_channel_id: sc.id,
sales_channel_name: sc.name,
needs_link: needsLink,
suggested_location_id: suggestedLocationId,
};
});
}
Link the stock location the way the dashboard would
When the plan calls for a link, call POST /admin/stock-locations/{stock_location_id}/sales-channels with the channel to add. Then re-fetch the channel with *stock_locations and confirm the target location is now present before you report success. Never guess a location; only act when one is explicit.
def link_stock_location(token, stock_location_id, sales_channel_id):
r = requests.post(
f"{BACKEND_URL}/admin/stock-locations/{stock_location_id}/sales-channels",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"add": [sales_channel_id], "remove": []},
timeout=30,
)
r.raise_for_status()
return r.json()
def confirm_linked(token, sales_channel_id, stock_location_id):
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels/{sales_channel_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*stock_locations"},
timeout=30,
)
r.raise_for_status()
linked_ids = {loc["id"] for loc in r.json()["sales_channel"]["stock_locations"]}
return stock_location_id in linked_ids
async function linkStockLocation(stockLocationId, salesChannelId) {
return sdk.admin.stockLocation.updateSalesChannels(stockLocationId, {
add: [salesChannelId],
remove: [],
});
}
async function confirmLinked(salesChannelId, stockLocationId) {
const { sales_channel } = await sdk.admin.salesChannel.retrieve(salesChannelId, {
fields: "id,*stock_locations",
});
const linkedIds = new Set(sales_channel.stock_locations.map((loc) => loc.id));
return linkedIds.has(stockLocationId);
}
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 channel it would link and to which stock location. Read the output, agree with it, then switch it off to let it write. It is safe to run again and again, since a channel that already has a linked location is simply left alone.
Always start with DRY_RUN=true, and never let the script pick a stock location on its own when more than one location exists. Set STOCK_LOCATION_ID explicitly, or only rely on the automatic default when the store truly has one obvious location.
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 sales channels with zero linked stock locations and link one, safely.
Inventory availability is scoped by a stored link between the Sales Channel
module and the Stock Location module. Reservations, location-scoped inventory
levels, and cart or checkout availability checks all resolve through that
link. If a sales channel has zero linked stock locations, every product
becomes effectively unpurchasable through it, even though the inventory
items have valid location levels elsewhere. This lists every sales channel,
decides what to do with a pure function, and only writes when a target
stock location is explicit or there is exactly one unambiguous default
location in the store. Every other case is reported only. Run once, or on
a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/medusa/sales-channel-not-linked-to-a-stock-location/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("link_stock_location")
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"
STOCK_LOCATION_ID_OVERRIDE = os.environ.get("STOCK_LOCATION_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_sales_channels(token):
channels = []
offset = 0
limit = 100
while True:
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,*stock_locations", "limit": limit, "offset": offset},
timeout=30,
)
r.raise_for_status()
body = r.json()
channels.extend(body["sales_channels"])
offset += limit
if offset >= body["count"]:
return channels
def get_stock_locations(token):
r = requests.get(
f"{BACKEND_URL}/admin/stock-locations",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,name,*sales_channels"},
timeout=30,
)
r.raise_for_status()
return r.json()["stock_locations"]
def plan_stock_location_links(sales_channels, available_locations, default_location_id=None):
"""Pure decision function. No I/O.
sales_channels: [{"id": str, "name": str, "stock_locations": [{"id": str}, ...]}, ...]
available_locations: [{"id": str, "name": str}, ...]
default_location_id: str | None
Returns a list of
{"sales_channel_id": str, "sales_channel_name": str, "needs_link": bool, "suggested_location_id": str | None}
for each sales channel, one entry per channel, order preserved.
"""
plans = []
for sc in sales_channels:
needs_link = len(sc.get("stock_locations") or []) == 0
suggested_location_id = None
if needs_link:
if default_location_id:
suggested_location_id = default_location_id
elif len(available_locations) == 1:
suggested_location_id = available_locations[0]["id"]
plans.append({
"sales_channel_id": sc["id"],
"sales_channel_name": sc.get("name"),
"needs_link": needs_link,
"suggested_location_id": suggested_location_id,
})
return plans
def link_stock_location(token, stock_location_id, sales_channel_id):
r = requests.post(
f"{BACKEND_URL}/admin/stock-locations/{stock_location_id}/sales-channels",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"add": [sales_channel_id], "remove": []},
timeout=30,
)
r.raise_for_status()
return r.json()
def confirm_linked(token, sales_channel_id, stock_location_id):
r = requests.get(
f"{BACKEND_URL}/admin/sales-channels/{sales_channel_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": "id,*stock_locations"},
timeout=30,
)
r.raise_for_status()
linked_ids = {loc["id"] for loc in r.json()["sales_channel"]["stock_locations"]}
return stock_location_id in linked_ids
def run():
token = get_admin_token()
sales_channels = get_sales_channels(token)
available_locations = get_stock_locations(token)
plans = plan_stock_location_links(sales_channels, available_locations, STOCK_LOCATION_ID_OVERRIDE)
linked = 0
flagged = 0
for plan in plans:
if not plan["needs_link"]:
log.info("Sales channel %s (%s): already linked to a stock location", plan["sales_channel_id"], plan["sales_channel_name"])
continue
if not plan["suggested_location_id"]:
log.info("Sales channel %s (%s): flagged, no stock location linked and no unambiguous default to link", plan["sales_channel_id"], plan["sales_channel_name"])
flagged += 1
continue
loc_id = plan["suggested_location_id"]
log.info(
"%s sales channel %s to stock location %s",
"Would link" if DRY_RUN else "Linking", plan["sales_channel_id"], loc_id,
)
if not DRY_RUN:
link_stock_location(token, loc_id, plan["sales_channel_id"])
if not confirm_linked(token, plan["sales_channel_id"], loc_id):
raise RuntimeError(f"Link did not take effect for sales channel {plan['sales_channel_id']}")
log.info("Confirmed. Sales channel %s is now linked to stock location %s.", plan["sales_channel_id"], loc_id)
linked += 1
log.info("Done. %d sales channel(s) %s, %d sales channel(s) flagged for review.", linked, "to link" if DRY_RUN else "linked", flagged)
if __name__ == "__main__":
run()
/**
* Find Medusa sales channels with zero linked stock locations and link one, safely.
*
* Inventory availability is scoped by a stored link between the Sales Channel
* module and the Stock Location module. Reservations, location-scoped inventory
* levels, and cart or checkout availability checks all resolve through that
* link. If a sales channel has zero linked stock locations, every product
* becomes effectively unpurchasable through it, even though the inventory
* items have valid location levels elsewhere. This lists every sales channel,
* decides what to do with a pure function, and only writes when a target
* stock location is explicit or there is exactly one unambiguous default
* location in the store. Every other case is reported only. Run once, or on
* a schedule.
*
* Guide: https://www.allanninal.dev/medusa/sales-channel-not-linked-to-a-stock-location/
*/
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 STOCK_LOCATION_ID_OVERRIDE = (process.env.STOCK_LOCATION_ID || "").trim() || null;
export function planStockLocationLinks(salesChannels, availableLocations, defaultLocationId) {
return salesChannels.map((sc) => {
const needsLink = (sc.stock_locations || []).length === 0;
let suggestedLocationId = null;
if (needsLink) {
if (defaultLocationId) {
suggestedLocationId = defaultLocationId;
} else if (availableLocations.length === 1) {
suggestedLocationId = availableLocations[0].id;
}
}
return {
sales_channel_id: sc.id,
sales_channel_name: sc.name,
needs_link: needsLink,
suggested_location_id: suggestedLocationId,
};
});
}
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 getSalesChannels(sdk) {
const channels = [];
let offset = 0;
const limit = 100;
while (true) {
const body = await sdk.admin.salesChannel.list({
fields: "id,name,*stock_locations",
limit,
offset,
});
channels.push(...body.sales_channels);
offset += limit;
if (offset >= body.count) return channels;
}
}
async function getStockLocations(sdk) {
const { stock_locations } = await sdk.admin.stockLocation.list({
fields: "id,name,*sales_channels",
});
return stock_locations;
}
async function linkStockLocation(sdk, stockLocationId, salesChannelId) {
return sdk.admin.stockLocation.updateSalesChannels(stockLocationId, {
add: [salesChannelId],
remove: [],
});
}
async function confirmLinked(sdk, salesChannelId, stockLocationId) {
const { sales_channel } = await sdk.admin.salesChannel.retrieve(salesChannelId, {
fields: "id,*stock_locations",
});
const linkedIds = new Set(sales_channel.stock_locations.map((loc) => loc.id));
return linkedIds.has(stockLocationId);
}
export async function run() {
const sdk = await getSdk();
const salesChannels = await getSalesChannels(sdk);
const availableLocations = await getStockLocations(sdk);
const plans = planStockLocationLinks(salesChannels, availableLocations, STOCK_LOCATION_ID_OVERRIDE);
let linked = 0;
let flagged = 0;
for (const plan of plans) {
if (!plan.needs_link) {
console.log(`Sales channel ${plan.sales_channel_id} (${plan.sales_channel_name}): already linked to a stock location`);
continue;
}
if (!plan.suggested_location_id) {
console.log(`Sales channel ${plan.sales_channel_id} (${plan.sales_channel_name}): flagged, no stock location linked and no unambiguous default to link`);
flagged++;
continue;
}
const locId = plan.suggested_location_id;
console.log(`${DRY_RUN ? "Would link" : "Linking"} sales channel ${plan.sales_channel_id} to stock location ${locId}`);
if (!DRY_RUN) {
await linkStockLocation(sdk, locId, plan.sales_channel_id);
const ok = await confirmLinked(sdk, plan.sales_channel_id, locId);
if (!ok) throw new Error(`Link did not take effect for sales channel ${plan.sales_channel_id}`);
console.log(`Confirmed. Sales channel ${plan.sales_channel_id} is now linked to stock location ${locId}.`);
}
linked++;
}
console.log(`Done. ${linked} sales channel(s) ${DRY_RUN ? "to link" : "linked"}, ${flagged} sales channel(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 live storefront's checkout gets pointed at a new stock location. Because we kept plan_stock_location_links pure, the test needs no network and no Medusa backend. It just feeds in plain arrays and objects and checks the answer.
from link_stock_location import plan_stock_location_links
def sc(**over):
base = {"id": "sc_1", "name": "Default channel", "stock_locations": []}
base.update(over)
return base
def loc(id_, name="Main warehouse"):
return {"id": id_, "name": name}
def test_channel_with_linked_location_needs_no_link():
plans = plan_stock_location_links([sc(stock_locations=[{"id": "sloc_1"}])], [loc("sloc_1")])
assert plans == [{
"sales_channel_id": "sc_1",
"sales_channel_name": "Default channel",
"needs_link": False,
"suggested_location_id": None,
}]
def test_channel_with_zero_locations_and_no_default_and_multiple_locations_is_flagged():
plans = plan_stock_location_links([sc()], [loc("sloc_1"), loc("sloc_2")])
assert plans == [{
"sales_channel_id": "sc_1",
"sales_channel_name": "Default channel",
"needs_link": True,
"suggested_location_id": None,
}]
def test_channel_with_zero_locations_and_exactly_one_available_location_is_suggested():
plans = plan_stock_location_links([sc()], [loc("sloc_1")])
assert plans == [{
"sales_channel_id": "sc_1",
"sales_channel_name": "Default channel",
"needs_link": True,
"suggested_location_id": "sloc_1",
}]
def test_explicit_default_location_wins_even_with_multiple_available():
plans = plan_stock_location_links([sc()], [loc("sloc_1"), loc("sloc_2")], default_location_id="sloc_2")
assert plans[0]["suggested_location_id"] == "sloc_2"
def test_multiple_channels_each_get_their_own_plan():
plans = plan_stock_location_links(
[sc(id="sc_1", stock_locations=[{"id": "sloc_1"}]), sc(id="sc_2", stock_locations=[])],
[loc("sloc_1")],
)
assert plans[0]["needs_link"] is False
assert plans[1]["needs_link"] is True
assert plans[1]["suggested_location_id"] == "sloc_1"
import { test } from "node:test";
import assert from "node:assert/strict";
import { planStockLocationLinks } from "./link-stock-location.js";
const sc = (over = {}) => ({ id: "sc_1", name: "Default channel", stock_locations: [], ...over });
const loc = (id, name = "Main warehouse") => ({ id, name });
test("channel with linked location needs no link", () => {
const plans = planStockLocationLinks([sc({ stock_locations: [{ id: "sloc_1" }] })], [loc("sloc_1")]);
assert.deepEqual(plans, [{
sales_channel_id: "sc_1",
sales_channel_name: "Default channel",
needs_link: false,
suggested_location_id: null,
}]);
});
test("channel with zero locations and no default and multiple locations is flagged", () => {
const plans = planStockLocationLinks([sc()], [loc("sloc_1"), loc("sloc_2")]);
assert.deepEqual(plans, [{
sales_channel_id: "sc_1",
sales_channel_name: "Default channel",
needs_link: true,
suggested_location_id: null,
}]);
});
test("channel with zero locations and exactly one available location is suggested", () => {
const plans = planStockLocationLinks([sc()], [loc("sloc_1")]);
assert.deepEqual(plans, [{
sales_channel_id: "sc_1",
sales_channel_name: "Default channel",
needs_link: true,
suggested_location_id: "sloc_1",
}]);
});
test("explicit default location wins even with multiple available", () => {
const plans = planStockLocationLinks([sc()], [loc("sloc_1"), loc("sloc_2")], "sloc_2");
assert.equal(plans[0].suggested_location_id, "sloc_2");
});
test("multiple channels each get their own plan", () => {
const plans = planStockLocationLinks(
[sc({ id: "sc_1", stock_locations: [{ id: "sloc_1" }] }), sc({ id: "sc_2", stock_locations: [] })],
[loc("sloc_1")],
);
assert.equal(plans[0].needs_link, false);
assert.equal(plans[1].needs_link, true);
assert.equal(plans[1].suggested_location_id, "sloc_1");
});
Case studies
The channel that was never linked in the first place
A team spun up a new sales channel for a marketplace app integration, wired up its publishable key, and pushed products to it. The catalog synced cleanly and the marketplace listing pages rendered, but every add to cart on that channel failed once a shopper tried to check out, with an error about the sales channel not being associated with any stock location.
Running the script in dry run showed one channel with zero linked stock locations and one obvious default warehouse in the store. Turning off dry run linked it in one call, and checkout started working immediately, no redeploy needed.
The closed location that took a channel down with it
A merchant closed a regional warehouse during a consolidation and removed its stock location, not realizing their secondary storefront's sales channel was linked only to that one location. Backorders on that storefront started erroring out the moment the change went live, and support tickets started before anyone connected it to the consolidation.
The detection step flagged the channel immediately, since it now had zero linked stock locations and there was more than one active location in the store, so no default was assumed. The team picked the correct replacement warehouse explicitly with STOCK_LOCATION_ID and relinked it in minutes.
After this runs, every sales channel either has a confirmed stock location link or is sitting in a clear flagged list with a reason attached. No channel gets linked to a location nobody chose, and a channel that already works is never touched. The gap between "channel exists" and "channel can actually fulfill an order" closes before a shopper ever hits a failed checkout.
FAQ
Why does my Medusa sales channel show products as unpurchasable even though inventory exists?
Inventory availability in Medusa v2 is scoped by a stored link between the Sales Channel module and the Stock Location module. Reservations, location-scoped inventory levels, and cart and checkout availability checks all resolve through that link to find which locations serve a channel. If a sales channel has zero linked stock locations, every product becomes effectively unpurchasable through it, even though the inventory items have valid location levels elsewhere.
Is it safe to automatically link a sales channel to a stock location?
Not by guessing. Linking to the wrong or an unintended stock location silently changes fulfillment and availability behavior for a live storefront, so the safe default is to flag the sales channel and only link it when a target stock location is explicit, or when dry run is off and there is exactly one unambiguous default location to use.
How do I check which stock locations a Medusa sales channel is linked to?
Call GET /admin/sales-channels with fields=id,name,*stock_locations using an admin Bearer token. The stock_locations array in the response is the live link. An empty array means the channel currently resolves to no stock location, so reservations and availability checks for that channel have nothing to work against.
Related field notes
Citations
On the problem:
- GitHub Issue: Error "Sales channel xxx is not associated with any stock location" when adding variant with backorder to cart. github.com/medusajs/medusa/issues/10694
- Medusa Documentation: Sales Channel Module, links to other modules (StockLocation link). docs.medusajs.com/resources/commerce-modules/sales-channel/links-to-other-modules
- GitHub Issue: Inventory reduction not reflecting correct stock location for sales channels. github.com/medusajs/medusa/issues/10658
On the solution:
- Medusa Documentation: Stock Location Module, links to other modules. docs.medusajs.com/resources/commerce-modules/stock-location/links-to-other-modules
- Medusa JS SDK Admin Reference: stockLocation, including updateSalesChannels. docs.medusajs.com/resources/references/js-sdk/admin/stockLocation
- 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 get checkout working again?
If this saved you a confusing afternoon chasing a phantom out of stock error, 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