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.

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

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.

Cart adds item through a sales channel Look up link SalesChannel to StockLocation zero locations linked No location scope stock exists elsewhere Cart or checkout fails
The channel authenticates fine and the inventory item has stock somewhere. It is the missing SalesChannel-to-StockLocation link that leaves the availability check with nothing to resolve.

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:

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.

The key insight

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.

List sales channels GET /admin/sales-channels Read stock_locations *stock_locations expansion Count linked locations is the array empty? Zero linked, default known? yes no, flag it link stock location channel now scoped
The script only links a channel when it has zero linked stock locations and the target location 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

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.

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_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
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 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;
  }
}
3

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.

step3.py
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"]
step3.js
async function getStockLocations() {
  const { stock_locations } = await sdk.admin.stockLocation.list({
    fields: "id,name,*sales_channels",
  });
  return stock_locations;
}
4

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.

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

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.

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

Run it safe

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.

link_stock_location.py
"""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()
link-stock-location.js
/**
 * 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.

test_sales_repair.py
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"
plan.test.js
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

Marketplace launch

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.

Warehouse consolidation

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.

What good looks like

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:

  1. 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
  2. Medusa Documentation: Sales Channel Module, links to other modules (StockLocation link). docs.medusajs.com/resources/commerce-modules/sales-channel/links-to-other-modules
  3. GitHub Issue: Inventory reduction not reflecting correct stock location for sales channels. github.com/medusajs/medusa/issues/10658

On the solution:

  1. Medusa Documentation: Stock Location Module, links to other modules. docs.medusajs.com/resources/commerce-modules/stock-location/links-to-other-modules
  2. Medusa JS SDK Admin Reference: stockLocation, including updateSalesChannels. docs.medusajs.com/resources/references/js-sdk/admin/stockLocation
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes