Skip to content

Diagnostic Products, Variants & Channels

Invalid channel slug accepted without error

You pass channel: "us" to products when the real slug is "us-store", or a channel got renamed and an old script never caught up. Saleor does not complain. It just hands back an empty list, or the pageInfo you expected to see growing stays flat, and nothing in the response tells you the channel argument was wrong. Here is why that gap exists and a script that catches it before your integration trusts an empty result.

Python and Node.js Saleor GraphQL API Safe by default (dry run)
Shopping online with a phone
Photo by Julio Lopez on Unsplash
The short answer

Saleor's channel-scoped queries, such as products, product, productVariant, and productVariants, resolve the channel argument by filtering channel listing records against the slug string you passed. They never first check that a Channel with that slug actually exists. A typo or a deleted channel simply matches zero listings, so you get an empty result instead of an error, which is the opposite of how checkoutCreate behaves, since it explicitly looks up the channel and raises CheckoutErrorCode.NOT_FOUND when it is missing. This is a confirmed, accepted inconsistency, not something you misconfigured. Run a Python or Node.js script that fetches the real channels list once, builds a set of valid and active slugs, and checks every slug your integration is about to use against that set before the channel-scoped query ever runs. Full code, tests, and a dry run guard are below.

The problem in plain words

Every channel-scoped query in Saleor, products(channel: $slug), product(id: $id, channel: $slug), productVariant(id: $id, channel: $slug), and productVariants(channel: $slug) among others, needs a channel to know which ChannelListing and availability records to filter by. The resolver takes the slug you gave it and uses it directly as a filter value. It never runs a separate step that says "does a Channel row with this slug exist at all."

When the slug is real, that filter matches the listings you expect. When the slug is wrong, misspelled, from a different store, or belongs to a channel that got renamed or deleted, the filter still runs. It just matches nothing. The resolver returns an empty edge list with pageInfo.hasNextPage: false, exactly the same shape Saleor returns for a channel that legitimately has zero products. There is no error, no warning, and no field anywhere in the response that says the channel slug itself was the problem.

products(channel: "us") is sent Filters listings by slug string no channel lookup "us" matches no real channel Filter matches zero listings Empty result, no error
The channel slug is used directly as a filter value. When it matches no real channel, the query still succeeds, it just returns nothing, which looks identical to a channel that genuinely has zero products.

Why it happens

The root cause is a design choice, not a random bug: channel-scoped resolvers filter ChannelListing and availability rows against the slug you gave, instead of resolving a Channel object first and failing fast if it does not exist. A few common ways teams end up hitting this:

This is a confirmed, accepted inconsistency in Saleor: checkoutCreate and similar mutations explicitly resolve the channel object first and raise CheckoutErrorCode.NOT_FOUND with the message Channel with '<slug>' does not exist. when it is missing, while channel-scoped queries do not do that same lookup. Maintainers have speculated part of the reason queries stayed this way is to avoid letting an unauthenticated caller enumerate which channel slugs are real by probing for errors. See the citations at the end for the exact issue and discussion threads.

The key insight

An empty result from a channel-scoped query means one of two very different things, and Saleor gives you no way to tell them apart from the response alone: either the channel is real and genuinely has no matching products, or the channel slug you passed does not exist at all. The only way to close that gap is to check the slug yourself, once, against the actual list of channels, before you ever trust an empty page as "no products."

The fix, as a flow

We do not touch checkout or any live query logic. We fetch the authoritative channel list once with channels { id slug name isActive }, using a staff or app token since non-staff callers cannot use that query, and build a set of valid slugs. Every place in the integration that is about to pass a channel argument gets checked against that set first, with one pure function that classifies the slug and suggests the closest real one when it is wrong.

Fetch channels once, staff token Build known slugs slug plus isActive decideChannelSlug Validity Status is VALID? yes no, flag with suggestion Run the query channel-scoped call
Every channel-scoped call site is checked against the real channel list first. Only a slug that classifies as VALID is allowed to reach the query, everything else is flagged before it can silently return an empty result.

Build it step by step

1

Get a staff or app token with channel read access

The channels query requires staff or app authentication, so a plain unauthenticated storefront token cannot use it. Create an app in the Saleor dashboard, or use tokenCreate with staff credentials, and grant it permission to manage or read channels. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   # start safe, no writes happen either way for this check
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   // start safe, no writes happen either way for this check
2

Talk to the Saleor GraphQL endpoint

Every call goes to the single GraphQL endpoint with your token in the Authorization: Bearer header. A small helper sends a query and returns the data, and raises if Saleor reports an error.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

Fetch the real channel list once

Query channels { id slug name isActive } to get the authoritative list of channels that actually exist in this store. This list is small, so there is no need to page it. Do this once at startup, or on a cache with a short refresh, rather than on every request.

step3.py
CHANNELS_QUERY = """
query {
  channels { id slug name isActive }
}"""

def fetch_channels():
    return gql(CHANNELS_QUERY)["channels"]
step3.js
const CHANNELS_QUERY = `
query {
  channels { id slug name isActive }
}`;

async function fetchChannels() {
  return (await gql(CHANNELS_QUERY)).channels;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the requested slug and the already-fetched channel list and returns a status plus a suggestion. A pure function like this is easy to read and easy to test, which we do later. It makes no network calls. If the requested slug matches a channel exactly, the status is VALID, or INACTIVE if that channel is turned off. If nothing matches, the status is UNKNOWN, and we look for the nearest known slug by edit distance to suggest as a fix, only when it is genuinely close.

decide.py
SUGGESTION_MAX_DISTANCE = 3

def _levenshtein(a, b):
    if a == b:
        return 0
    if not a:
        return len(b)
    if not b:
        return len(a)
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, start=1):
        curr = [i] + [0] * len(b)
        for j, cb in enumerate(b, start=1):
            cost = 0 if ca == cb else 1
            curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
        prev = curr
    return prev[len(b)]


def decide_channel_slug_validity(requested_slug, known_channels):
    for channel in known_channels:
        if channel["slug"] == requested_slug:
            status = "VALID" if channel.get("isActive", True) else "INACTIVE"
            return {"status": status, "suggestion": None}

    best_slug = None
    best_distance = None
    for channel in known_channels:
        slug = channel["slug"]
        distance = _levenshtein(requested_slug, slug)
        shares_prefix = (
            len(requested_slug) >= 2
            and len(slug) >= 2
            and requested_slug[:2] == slug[:2]
        )
        if distance <= SUGGESTION_MAX_DISTANCE or shares_prefix:
            if best_distance is None or distance < best_distance:
                best_distance = distance
                best_slug = slug

    return {"status": "UNKNOWN", "suggestion": best_slug}
decide.js
const SUGGESTION_MAX_DISTANCE = 3;

function levenshtein(a, b) {
  if (a === b) return 0;
  if (!a) return b.length;
  if (!b) return a.length;
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
  for (let i = 1; i <= a.length; i++) {
    const curr = [i];
    for (let j = 1; j <= b.length; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
    }
    prev = curr;
  }
  return prev[b.length];
}

export function decideChannelSlugValidity(requestedSlug, knownChannels) {
  for (const channel of knownChannels) {
    if (channel.slug === requestedSlug) {
      const status = channel.isActive === false ? "INACTIVE" : "VALID";
      return { status, suggestion: null };
    }
  }

  let bestSlug = null;
  let bestDistance = null;
  for (const channel of knownChannels) {
    const slug = channel.slug;
    const distance = levenshtein(requestedSlug, slug);
    const sharesPrefix =
      requestedSlug.length >= 2 && slug.length >= 2 && requestedSlug.slice(0, 2) === slug.slice(0, 2);
    if (distance <= SUGGESTION_MAX_DISTANCE || sharesPrefix) {
      if (bestDistance === null || distance < bestDistance) {
        bestDistance = distance;
        bestSlug = slug;
      }
    }
  }

  return { status: "UNKNOWN", suggestion: bestSlug };
}
5

Check every call site before it queries

Wherever your integration is about to pass a channel argument to a channel-scoped query or mutation, run the slug through decide_channel_slug_validity first. On UNKNOWN, fail fast with the call site and the suggested slug rather than letting the query return a silent empty result. On INACTIVE, log a warning, since the slug is real but the channel is turned off, which is a softer kind of silent failure worth flagging too.

guard.py
class InvalidChannelSlugError(Exception):
    pass


def require_valid_channel(requested_slug, known_channels, call_site=""):
    decision = decide_channel_slug_validity(requested_slug, known_channels)
    if decision["status"] == "UNKNOWN":
        hint = f" Did you mean '{decision['suggestion']}'?" if decision["suggestion"] else ""
        raise InvalidChannelSlugError(
            f"Channel slug '{requested_slug}' does not exist{' (' + call_site + ')' if call_site else ''}.{hint}"
        )
    if decision["status"] == "INACTIVE":
        log.warning("Channel slug '%s' is real but inactive (%s).", requested_slug, call_site)
    return decision
guard.js
export class InvalidChannelSlugError extends Error {}

export function requireValidChannel(requestedSlug, knownChannels, callSite = "") {
  const decision = decideChannelSlugValidity(requestedSlug, knownChannels);
  if (decision.status === "UNKNOWN") {
    const hint = decision.suggestion ? ` Did you mean '${decision.suggestion}'?` : "";
    throw new InvalidChannelSlugError(
      `Channel slug '${requestedSlug}' does not exist${callSite ? ` (${callSite})` : ""}.${hint}`
    );
  }
  if (decision.status === "INACTIVE") {
    console.warn(`Channel slug '${requestedSlug}' is real but inactive (${callSite}).`);
  }
  return decision;
}
Run it safe

This script never mutates the store. It only reads channels and reports. There is nothing to auto-repair here, since the invalid slug lives in your script or config, not in Saleor's data. DRY_RUN only gates the optional step of rewriting your own config to pull slugs from a validated lookup instead of a hardcoded string, never a call against Saleor itself.

The full code

Here is the complete script in one file for each language. It fetches the channel list, classifies every slug you give it, and raises a clear InvalidChannelSlugError before any channel-scoped query has a chance to return a misleading empty result.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
validate_channel_slug.py
"""Catch an invalid Saleor channel slug before a channel-scoped query silently
returns nothing.

Saleor's channel-scoped queries, such as products, product, productVariant, and
productVariants, resolve the channel argument by filtering ChannelListing and
availability records against the slug string you pass. They never first check
that a Channel with that slug exists. A typo, a renamed channel, or a deleted
channel simply matches zero listings, so the query returns an empty result set
instead of an error, unlike mutations such as checkoutCreate which raise
CheckoutErrorCode.NOT_FOUND for the same situation (see saleor/saleor#16186).

This script fetches the real channel list once with channels { slug isActive },
then lets you validate any slug your integration is about to use with a pure
decision function before the channel-scoped query ever runs. It never writes
to Saleor. It only reads channels and reports.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("validate_channel_slug")

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Slugs your integration passes to channel-scoped queries, e.g. gathered from
# config, environment variables, or scanning your own script's call sites.
CANDIDATE_SLUGS = [
    s.strip() for s in os.environ.get("CANDIDATE_CHANNEL_SLUGS", "").split(",") if s.strip()
]

SUGGESTION_MAX_DISTANCE = 3

CHANNELS_QUERY = """
query {
  channels { id slug name isActive }
}"""


class InvalidChannelSlugError(Exception):
    pass


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def fetch_channels():
    return gql(CHANNELS_QUERY)["channels"]


def _levenshtein(a, b):
    if a == b:
        return 0
    if not a:
        return len(b)
    if not b:
        return len(a)
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, start=1):
        curr = [i] + [0] * len(b)
        for j, cb in enumerate(b, start=1):
            cost = 0 if ca == cb else 1
            curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
        prev = curr
    return prev[len(b)]


def decide_channel_slug_validity(requested_slug, known_channels):
    """Pure decision logic. Takes an already-fetched channel list and a candidate
    slug, no network calls. Returns {"status": "VALID"|"INACTIVE"|"UNKNOWN",
    "suggestion": str|None}."""
    for channel in known_channels:
        if channel["slug"] == requested_slug:
            status = "VALID" if channel.get("isActive", True) else "INACTIVE"
            return {"status": status, "suggestion": None}

    best_slug = None
    best_distance = None
    for channel in known_channels:
        slug = channel["slug"]
        distance = _levenshtein(requested_slug, slug)
        shares_prefix = (
            len(requested_slug) >= 2
            and len(slug) >= 2
            and requested_slug[:2] == slug[:2]
        )
        if distance <= SUGGESTION_MAX_DISTANCE or shares_prefix:
            if best_distance is None or distance < best_distance:
                best_distance = distance
                best_slug = slug

    return {"status": "UNKNOWN", "suggestion": best_slug}


def require_valid_channel(requested_slug, known_channels, call_site=""):
    decision = decide_channel_slug_validity(requested_slug, known_channels)
    if decision["status"] == "UNKNOWN":
        hint = f" Did you mean '{decision['suggestion']}'?" if decision["suggestion"] else ""
        raise InvalidChannelSlugError(
            f"Channel slug '{requested_slug}' does not exist"
            f"{' (' + call_site + ')' if call_site else ''}.{hint}"
        )
    if decision["status"] == "INACTIVE":
        log.warning("Channel slug '%s' is real but inactive (%s).", requested_slug, call_site)
    return decision


def run():
    known_channels = fetch_channels()
    log.info("Fetched %d channel(s) from Saleor.", len(known_channels))

    if not CANDIDATE_SLUGS:
        log.info(
            "No CANDIDATE_CHANNEL_SLUGS set. Set it to a comma separated list of "
            "slugs your integration uses to check them against the real list."
        )
        return

    problems = 0
    for slug in CANDIDATE_SLUGS:
        try:
            decision = require_valid_channel(slug, known_channels, call_site="CANDIDATE_CHANNEL_SLUGS")
        except InvalidChannelSlugError as err:
            problems += 1
            log.error(str(err))
            continue
        log.info("Channel slug '%s' is %s.", slug, decision["status"])

    if problems and not DRY_RUN:
        raise InvalidChannelSlugError(
            f"{problems} channel slug(s) failed validation. Fix your config before querying Saleor."
        )
    log.info("Done. %d of %d slug(s) failed validation.", problems, len(CANDIDATE_SLUGS))


if __name__ == "__main__":
    run()
validate-channel-slug.js
/**
 * Catch an invalid Saleor channel slug before a channel-scoped query silently
 * returns nothing.
 *
 * Saleor's channel-scoped queries, such as products, product, productVariant, and
 * productVariants, resolve the channel argument by filtering ChannelListing and
 * availability records against the slug string you pass. They never first check
 * that a Channel with that slug exists. A typo, a renamed channel, or a deleted
 * channel simply matches zero listings, so the query returns an empty result set
 * instead of an error, unlike mutations such as checkoutCreate which raise
 * CheckoutErrorCode.NOT_FOUND for the same situation (see saleor/saleor#16186).
 *
 * This script fetches the real channel list once with channels { slug isActive },
 * then lets you validate any slug your integration is about to use with a pure
 * decision function before the channel-scoped query ever runs. It never writes
 * to Saleor. It only reads channels and reports.
 *
 * Guide: https://www.allanninal.dev/saleor/invalid-channel-slug-accepted-silently/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Slugs your integration passes to channel-scoped queries, e.g. gathered from
// config, environment variables, or scanning your own script's call sites.
const CANDIDATE_SLUGS = (process.env.CANDIDATE_CHANNEL_SLUGS || "")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);

const SUGGESTION_MAX_DISTANCE = 3;

const CHANNELS_QUERY = `
query {
  channels { id slug name isActive }
}`;

export class InvalidChannelSlugError extends Error {}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

async function fetchChannels() {
  return (await gql(CHANNELS_QUERY)).channels;
}

function levenshtein(a, b) {
  if (a === b) return 0;
  if (!a) return b.length;
  if (!b) return a.length;
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
  for (let i = 1; i <= a.length; i++) {
    const curr = [i];
    for (let j = 1; j <= b.length; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
    }
    prev = curr;
  }
  return prev[b.length];
}

/**
 * Pure decision logic. Takes an already-fetched channel list and a candidate
 * slug, no network calls. Returns { status: "VALID"|"INACTIVE"|"UNKNOWN",
 * suggestion: string|null }.
 */
export function decideChannelSlugValidity(requestedSlug, knownChannels) {
  for (const channel of knownChannels) {
    if (channel.slug === requestedSlug) {
      const status = channel.isActive === false ? "INACTIVE" : "VALID";
      return { status, suggestion: null };
    }
  }

  let bestSlug = null;
  let bestDistance = null;
  for (const channel of knownChannels) {
    const slug = channel.slug;
    const distance = levenshtein(requestedSlug, slug);
    const sharesPrefix =
      requestedSlug.length >= 2 && slug.length >= 2 && requestedSlug.slice(0, 2) === slug.slice(0, 2);
    if (distance <= SUGGESTION_MAX_DISTANCE || sharesPrefix) {
      if (bestDistance === null || distance < bestDistance) {
        bestDistance = distance;
        bestSlug = slug;
      }
    }
  }

  return { status: "UNKNOWN", suggestion: bestSlug };
}

export function requireValidChannel(requestedSlug, knownChannels, callSite = "") {
  const decision = decideChannelSlugValidity(requestedSlug, knownChannels);
  if (decision.status === "UNKNOWN") {
    const hint = decision.suggestion ? ` Did you mean '${decision.suggestion}'?` : "";
    throw new InvalidChannelSlugError(
      `Channel slug '${requestedSlug}' does not exist${callSite ? ` (${callSite})` : ""}.${hint}`
    );
  }
  if (decision.status === "INACTIVE") {
    console.warn(`Channel slug '${requestedSlug}' is real but inactive (${callSite}).`);
  }
  return decision;
}

export async function run() {
  const knownChannels = await fetchChannels();
  console.log(`Fetched ${knownChannels.length} channel(s) from Saleor.`);

  if (CANDIDATE_SLUGS.length === 0) {
    console.log(
      "No CANDIDATE_CHANNEL_SLUGS set. Set it to a comma separated list of "
      + "slugs your integration uses to check them against the real list."
    );
    return;
  }

  let problems = 0;
  for (const slug of CANDIDATE_SLUGS) {
    try {
      const decision = requireValidChannel(slug, knownChannels, "CANDIDATE_CHANNEL_SLUGS");
      console.log(`Channel slug '${slug}' is ${decision.status}.`);
    } catch (err) {
      problems++;
      console.error(err.message);
    }
  }

  if (problems && !DRY_RUN) {
    throw new InvalidChannelSlugError(
      `${problems} channel slug(s) failed validation. Fix your config before querying Saleor.`
    );
  }
  console.log(`Done. ${problems} of ${CANDIDATE_SLUGS.length} slug(s) failed validation.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides whether your integration trusts an empty result or fails fast with a useful suggestion. Because we kept decide_channel_slug_validity pure, the test needs no network and no Saleor store. It just feeds in plain data and checks the answer.

test_invalid_channel_slug.py
from validate_channel_slug import decide_channel_slug_validity

CHANNELS = [
    {"slug": "default-channel", "isActive": True},
    {"slug": "us-store", "isActive": True},
    {"slug": "eu-store", "isActive": False},
]


def test_exact_match_is_valid():
    assert decide_channel_slug_validity("us-store", CHANNELS) == {"status": "VALID", "suggestion": None}


def test_exact_match_on_inactive_channel_is_inactive():
    assert decide_channel_slug_validity("eu-store", CHANNELS) == {"status": "INACTIVE", "suggestion": None}


def test_typo_is_unknown_with_close_suggestion():
    result = decide_channel_slug_validity("us-stor", CHANNELS)
    assert result["status"] == "UNKNOWN"
    assert result["suggestion"] == "us-store"


def test_completely_unrelated_slug_has_no_suggestion():
    result = decide_channel_slug_validity("zzz-totally-different", CHANNELS)
    assert result["status"] == "UNKNOWN"
    assert result["suggestion"] is None


def test_empty_known_channels_is_unknown_with_no_suggestion():
    assert decide_channel_slug_validity("us-store", []) == {"status": "UNKNOWN", "suggestion": None}


def test_short_prefix_match_can_still_suggest():
    result = decide_channel_slug_validity("us", CHANNELS)
    assert result["status"] == "UNKNOWN"
    assert result["suggestion"] == "us-store"
invalid-channel-slug.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideChannelSlugValidity } from "./validate-channel-slug.js";

const CHANNELS = [
  { slug: "default-channel", isActive: true },
  { slug: "us-store", isActive: true },
  { slug: "eu-store", isActive: false },
];

test("exact match is valid", () =&gt; {
  assert.deepEqual(decideChannelSlugValidity("us-store", CHANNELS), { status: "VALID", suggestion: null });
});

test("exact match on inactive channel is inactive", () =&gt; {
  assert.deepEqual(decideChannelSlugValidity("eu-store", CHANNELS), { status: "INACTIVE", suggestion: null });
});

test("typo is unknown with close suggestion", () =&gt; {
  const result = decideChannelSlugValidity("us-stor", CHANNELS);
  assert.equal(result.status, "UNKNOWN");
  assert.equal(result.suggestion, "us-store");
});

test("completely unrelated slug has no suggestion", () =&gt; {
  const result = decideChannelSlugValidity("zzz-totally-different", CHANNELS);
  assert.equal(result.status, "UNKNOWN");
  assert.equal(result.suggestion, null);
});

test("empty known channels is unknown with no suggestion", () =&gt; {
  assert.deepEqual(decideChannelSlugValidity("us-store", []), { status: "UNKNOWN", suggestion: null });
});

test("short prefix match can still suggest", () =&gt; {
  const result = decideChannelSlugValidity("us", CHANNELS);
  assert.equal(result.status, "UNKNOWN");
  assert.equal(result.suggestion, "us-store");
});

Case studies

Channel rename

The dashboard sync job that went quiet for a week

A store renamed its main channel slug during a rebrand, from "main" to "flagship", and updated the dashboard settings the same afternoon. A nightly sync job that pulled products(channel: "main") into a search index kept running without a single error, it just stopped finding new products because the old slug matched nothing anymore.

The search index quietly went stale for a week before a customer complained a new arrival was not searchable. Running the channel slug check against the sync job's hardcoded config immediately flagged "main" as UNKNOWN with the suggestion "flagship", and a one-line config fix brought the index back to life.

Environment mix-up

The staging slug that leaked into production config

A developer copied a working .env file from staging into a new production deployment to save time, including SALEOR_CHANNEL_SLUG=staging-channel. Production had no channel with that slug. Every channel-scoped query the app made came back empty, and the storefront looked like an empty catalog rather than a misconfiguration.

Adding the channel slug check to the deployment's startup script turned a confusing "why is the whole store empty" incident into an immediate, specific failure: Channel slug 'staging-channel' does not exist. Did you mean 'production-channel'? The fix took one environment variable change instead of an afternoon of debugging.

What good looks like

After adding this check at startup, an invalid channel slug fails loudly and specifically, with the exact slug, the call site, and a suggested correction, instead of quietly returning an empty catalog that looks identical to a channel with zero products. Nothing about Saleor's query behavior needs to change. The gap between "no products" and "no such channel" simply gets closed in your own code, before the first channel-scoped query ever runs.

FAQ

Why does Saleor return zero products instead of an error for a wrong channel slug?

Channel-scoped queries such as products, product, and productVariants resolve the channel argument by filtering ChannelListing and availability records against the slug string you passed. They do not first look up and confirm a Channel with that slug exists. When the slug matches no real channel, the filter simply matches zero listings, so the resolver returns an empty result set instead of raising a GraphQL error. Saleor maintainers confirmed this as a real, accepted inconsistency in issue #16186.

Is this the same everywhere in the Saleor API?

No. Mutations like checkoutCreate resolve the channel object first and raise CheckoutErrorCode.NOT_FOUND with the message Channel with '<slug>' does not exist. when it is missing. Channel-scoped queries do not do that lookup, so the same kind of typo behaves completely differently depending on whether you are querying or checking out, which is the exact inconsistency this issue describes.

How do I catch an invalid channel slug before it silently breaks a query?

Fetch the channels query once with a staff or app token, build a set of valid slugs from channels { slug isActive }, and check every slug your integration is about to use against that set before sending the channel-scoped query. A pure function can classify a slug as VALID, INACTIVE, or UNKNOWN, and suggest the closest real slug by edit distance so the fix is obvious.

Related field notes

Citations

On the problem:

  1. Bug: wrong channel slug is not raising an error. Issue #16186, saleor/saleor. github.com/saleor/saleor/issues/16186
  2. GraphQL returns NULL for channel, pricing and others. Discussion #13045, saleor/saleor. github.com/saleor/saleor/discussions/13045
  3. Errors due to channel filter sent by storefront queries. Issue #7601, saleor/saleor. github.com/saleor/saleor/issues/7601

On the solution:

  1. Saleor Developer Docs: Channel API guide. docs.saleor.io/developer/channels/api
  2. Saleor API Reference: the checkoutCreate mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-create
  3. Saleor API Reference: the CheckoutErrorCode enum. docs.saleor.io/api-reference/checkout/enums/checkout-error-code

Fighting a Saleor bug right now?

If you have a problem in Saleor checkout, channels, shipping, or fulfillment 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 catch a bad channel slug?

If this saved you a night chasing an empty catalog that turned out to be a typo, 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 Saleor field notes