Skip to content

Reconciler Shipping & Warehouses

No shipping methods available for a channel

A shipping zone exists. It covers the customer's country. The method is sitting right there in the zone. And checkout still comes back with an empty shipping methods list, no error, nothing to click. The zone and the method are only half the story in Saleor. Here is the piece that is usually missing, and a script that checks every channel for it.

Python and Node.js Saleor GraphQL API Safe by default (dry run)
A courier on a scooter
Photo by Lucian Alexe on Unsplash
The short answer

In Saleor a shipping method only becomes usable at checkout when three things line up: the shipping zone covers the customer's country, a warehouse in that zone is assigned to the channel, and the method has its own ShippingMethodChannelListing for that channel, created with shippingMethodChannelListingUpdate. It is easy to build the zone, add the channel to it, and add the method, then forget the separate per-channel listing step. Run a Python or Node.js script that queries every shippingZones node alongside channels, and flags any channel missing a zone link, a channel-assigned warehouse, or a listed method. Full code, tests, and a dry run guard are below.

The problem in plain words

Saleor treats shipping as three separate objects that all have to agree before a customer sees a shipping option: the ShippingZone, which says which countries are covered; the warehouses inside that zone, which have to be assigned to the channel; and the ShippingMethod, which needs its own price and currency for that channel through a ShippingMethodChannelListing.

Creating a zone and adding a channel to it with addChannels feels like the whole job. It is not. A method can sit inside a zone that is fully linked to a channel and still be invisible for that channel's checkouts, because nobody ran shippingMethodChannelListingUpdate for it. checkout.shippingMethods, availableShippingMethods, and the newer deliveryOptionsCalculate mutation all just return an empty array in this case. No error, no warning, nothing pointing at the missing listing.

ShippingZone covers the country Zone has channel addChannels applied ShippingMethod exists inside the zone no channel listing No price for this channel Checkout list is empty
The zone is right, the channel link is right, the method exists, but without its own channel listing the method never reaches that channel's checkout.

Why it happens

Shipping in Saleor is modeled as several linked objects rather than one setting, and each one has to be wired to the channel on its own. A few common ways teams end up with an empty list:

This is a well known rough edge in the shipping API shape, and the empty list with no error is exactly the kind of thing that gets reported as a bug when it is really a missing configuration step. See the citations at the end for the exact issues and docs.

The key insight

An empty shipping methods list for a channel is not one problem, it is three possible problems that all look identical from checkout. The zone might not be linked to the channel, the zone's warehouses might not be linked to the channel, or the method might be missing its channel listing. Picking the wrong one to fix can misconfigure pricing, so the safe move is to detect and report which of the three applies, not to guess and write.

The fix, as a flow

We do not touch checkout directly. We query every channel and every shipping zone, cross reference them in memory with one pure function, and report exactly which reason applies for each broken channel: no zone, no channel-assigned warehouse, or no listed method. Only where the intent is unambiguous, a missing listing on a zone already fully scoped to one channel, does a dry-run-guarded repair print the mutation it would run.

Query channels and shipping zones Cross reference zones, warehouses, listings findChannelsMissing ShippingCoverage Covered for every channel? yes, nothing to do no, flag reason Report or, if unambiguous, dry run
Every channel is classified against the shipping zones. A gap is reported with its exact reason, and only a clearly unambiguous missing listing gets a dry-run-printed repair mutation.

Build it step by step

1

Get an app token with shipping and channel scopes

Create an app in the Saleor dashboard, or use tokenCreate with staff credentials, and grant it permission to manage shipping and read channels and orders. 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, change to false to print repair mutations
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, change to false to print repair mutations
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 channels and shipping zones together

Query the active channels, then query every shipping zone with its channels, its warehouses and each warehouse's channels, and each shippingMethod's channelListings. This is the whole picture the decision needs, in two queries.

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

ZONES_QUERY = """
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id }
        warehouses { id channels { id } }
        shippingMethods { id name channelListings { channel { id } price { amount } } }
      }
    }
  }
}"""

def fetch_channels_and_zones():
    channels = gql(CHANNELS_QUERY)["channels"]
    zones = [e["node"] for e in gql(ZONES_QUERY)["shippingZones"]["edges"]]
    return channels, zones
step3.js
const CHANNELS_QUERY = `
query { channels { id name slug } }`;

const ZONES_QUERY = `
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id }
        warehouses { id channels { id } }
        shippingMethods { id name channelListings { channel { id } price { amount } } }
      }
    }
  }
}`;

async function fetchChannelsAndZones() {
  const channels = (await gql(CHANNELS_QUERY)).channels;
  const zones = (await gql(ZONES_QUERY)).shippingZones.edges.map((e) => e.node);
  return { channels, zones };
}
4

Decide, with one pure function

Keep the decision in its own function that takes the channels and zones already fetched and returns a list of flagged channels with a reason. A pure function like this is easy to read and easy to test, which we do later. For each channel, first look for zones whose channels include it. If none, the reason is NO_ZONE. Otherwise check whether any of those zones has a warehouse assigned to the channel; if not, NO_WAREHOUSE_IN_CHANNEL. Otherwise check whether any shipping method across those zones has a channel listing for the channel; if not, NO_METHOD_LISTED. Anything else is not flagged.

decide.py
def find_channels_missing_shipping_coverage(channels, shipping_zones):
    flagged = []
    for channel in channels:
        cid = channel["id"]
        zones_for_channel = [
            z for z in shipping_zones
            if any(c["id"] == cid for c in z.get("channels", []))
        ]
        if not zones_for_channel:
            flagged.append({"channelId": cid, "reason": "NO_ZONE"})
            continue

        has_warehouse = any(
            any(c["id"] == cid for c in wh.get("channels", []))
            for z in zones_for_channel
            for wh in z.get("warehouses", [])
        )
        if not has_warehouse:
            flagged.append({"channelId": cid, "reason": "NO_WAREHOUSE_IN_CHANNEL"})
            continue

        has_listed_method = any(
            any(cl["channel"]["id"] == cid for cl in m.get("channelListings", []))
            for z in zones_for_channel
            for m in z.get("shippingMethods", [])
        )
        if not has_listed_method:
            flagged.append({"channelId": cid, "reason": "NO_METHOD_LISTED"})

    return flagged
decide.js
export function findChannelsMissingShippingCoverage(channels, shippingZones) {
  const flagged = [];
  for (const channel of channels) {
    const cid = channel.id;
    const zonesForChannel = shippingZones.filter((z) =>
      (z.channels || []).some((c) => c.id === cid)
    );
    if (zonesForChannel.length === 0) {
      flagged.push({ channelId: cid, reason: "NO_ZONE" });
      continue;
    }

    const hasWarehouse = zonesForChannel.some((z) =>
      (z.warehouses || []).some((wh) => (wh.channels || []).some((c) => c.id === cid))
    );
    if (!hasWarehouse) {
      flagged.push({ channelId: cid, reason: "NO_WAREHOUSE_IN_CHANNEL" });
      continue;
    }

    const hasListedMethod = zonesForChannel.some((z) =>
      (z.shippingMethods || []).some((m) =>
        (m.channelListings || []).some((cl) => cl.channel.id === cid)
      )
    );
    if (!hasListedMethod) {
      flagged.push({ channelId: cid, reason: "NO_METHOD_LISTED" });
    }
  }
  return flagged;
}
5

Cross-check against real orders

A flagged channel is worth acting on faster if customers are already checking out through it. Query orders(filter:{channels:[id]}) for the channel and look at each order's shippingMethod to see whether real checkouts are already missing shipping, rather than the gap being theoretical.

crosscheck.py
ORDERS_QUERY = """
query($channelId: ID!) {
  orders(first: 20, filter: { channels: [$channelId] }) {
    edges { node { id shippingMethod { id } } }
  }
}"""

def impacted_orders(channel_id):
    data = gql(ORDERS_QUERY, {"channelId": channel_id})["orders"]
    return [e["node"] for e in data["edges"] if not e["node"].get("shippingMethod")]
crosscheck.js
const ORDERS_QUERY = `
query($channelId: ID!) {
  orders(first: 20, filter: { channels: [$channelId] }) {
    edges { node { id shippingMethod { id } } }
  }
}`;

async function impactedOrders(channelId) {
  const data = (await gql(ORDERS_QUERY, { channelId })).orders;
  return data.edges.map((e) => e.node).filter((n) => !n.shippingMethod);
}
6

Report first, repair only the unambiguous case, under dry run

The default action is to print a report of every flagged channel and its reason. Do not auto-write a fix, because the correct price and currency for a channel listing, or whether a zone or warehouse should join a channel, are business decisions. Only when a method's zone is already fully scoped to a single channel and the reason is NO_METHOD_LISTED does the script print the exact shippingMethodChannelListingUpdate call it would run, gated by DRY_RUN.

Run it safe

Always start with DRY_RUN=true. This script never sends shippingZoneUpdate, warehouseUpdate, or shippingMethodChannelListingUpdate on its own. It reports the gap and, for the one unambiguous case, prints the mutation it would run so a human can review price and currency before anything is sent.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever prints a planned mutation rather than sending a write it cannot justify.

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.
find_missing_shipping_coverage.py
"""Flag Saleor channels that have no usable shipping methods, and why.

A ShippingMethod is only usable at checkout for a channel when the zone covers
the channel, a warehouse in that zone is assigned to the channel, and the method
has its own ShippingMethodChannelListing for that channel. This queries channels
and shipping zones, classifies each channel as NO_ZONE, NO_WAREHOUSE_IN_CHANNEL,
NO_METHOD_LISTED, or not flagged, and reports it. It never writes blindly: a repair
mutation is only ever printed under DRY_RUN, and only for the unambiguous case of a
method whose zone is already fully scoped to one channel and is only missing the
per-channel listing.
"""
import os
import logging
import requests

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

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"

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

ZONES_QUERY = """
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id }
        warehouses { id channels { id } }
        shippingMethods { id name channelListings { channel { id } price { amount } } }
      }
    }
  }
}"""

SHIPPING_METHOD_CHANNEL_LISTING_UPDATE = """
mutation($id: ID!, $input: ShippingMethodChannelListingInput!) {
  shippingMethodChannelListingUpdate(id: $id, input: $input) {
    shippingMethod { id }
    errors { field message }
  }
}"""


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 find_channels_missing_shipping_coverage(channels, shipping_zones):
    flagged = []
    for channel in channels:
        cid = channel["id"]
        zones_for_channel = [
            z for z in shipping_zones
            if any(c["id"] == cid for c in z.get("channels", []))
        ]
        if not zones_for_channel:
            flagged.append({"channelId": cid, "reason": "NO_ZONE"})
            continue

        has_warehouse = any(
            any(c["id"] == cid for c in wh.get("channels", []))
            for z in zones_for_channel
            for wh in z.get("warehouses", [])
        )
        if not has_warehouse:
            flagged.append({"channelId": cid, "reason": "NO_WAREHOUSE_IN_CHANNEL"})
            continue

        has_listed_method = any(
            any(cl["channel"]["id"] == cid for cl in m.get("channelListings", []))
            for z in zones_for_channel
            for m in z.get("shippingMethods", [])
        )
        if not has_listed_method:
            flagged.append({"channelId": cid, "reason": "NO_METHOD_LISTED"})

    return flagged


def fetch_channels_and_zones():
    channels = gql(CHANNELS_QUERY)["channels"]
    zones = [e["node"] for e in gql(ZONES_QUERY)["shippingZones"]["edges"]]
    return channels, zones


def find_unambiguous_repair(channel, shipping_zones):
    """A repair is unambiguous only when exactly one zone covers the channel,
    that zone has no other channels, and it has at least one shipping method
    with no listing for this channel yet."""
    cid = channel["id"]
    zones_for_channel = [
        z for z in shipping_zones
        if any(c["id"] == cid for c in z.get("channels", []))
    ]
    if len(zones_for_channel) != 1:
        return None
    zone = zones_for_channel[0]
    if len(zone.get("channels", [])) != 1:
        return None
    for method in zone.get("shippingMethods", []):
        if not any(cl["channel"]["id"] == cid for cl in method.get("channelListings", [])):
            return {"shippingMethodId": method["id"], "shippingMethodName": method["name"]}
    return None


def print_planned_listing_update(shipping_method_id, channel_id, currency):
    variables = {
        "id": shipping_method_id,
        "input": {"addChannels": [{"channelId": channel_id, "price": "0.00", "currency": currency}]},
    }
    log.info("DRY RUN would call shippingMethodChannelListingUpdate: %s", variables)


def run():
    channels, zones = fetch_channels_and_zones()
    flagged = find_channels_missing_shipping_coverage(channels, zones)
    by_id = {c["id"]: c for c in channels}

    if not flagged:
        log.info("Every channel has at least one usable shipping method.")
        return

    for item in flagged:
        channel = by_id[item["channelId"]]
        log.warning("Channel %s (%s) has no usable shipping methods: %s",
                    channel["name"], channel["slug"], item["reason"])
        if item["reason"] == "NO_METHOD_LISTED":
            repair = find_unambiguous_repair(channel, zones)
            if repair:
                log.info("Unambiguous repair candidate: method %s is missing a listing.",
                         repair["shippingMethodName"])
                if DRY_RUN:
                    print_planned_listing_update(
                        repair["shippingMethodId"], channel["id"], channel["currencyCode"]
                    )
                else:
                    log.warning("DRY_RUN is false, but this script only prints planned "
                                "repairs. Review the printed mutation and apply it by hand "
                                "or from your own reviewed tooling.")

    log.info("Done. %d channel(s) flagged.", len(flagged))


if __name__ == "__main__":
    run()
find-missing-shipping-coverage.js
/**
 * Flag Saleor channels that have no usable shipping methods, and why.
 *
 * A ShippingMethod is only usable at checkout for a channel when the zone covers
 * the channel, a warehouse in that zone is assigned to the channel, and the method
 * has its own ShippingMethodChannelListing for that channel. This queries channels
 * and shipping zones, classifies each channel as NO_ZONE, NO_WAREHOUSE_IN_CHANNEL,
 * NO_METHOD_LISTED, or not flagged, and reports it. It never writes blindly: a repair
 * mutation is only ever printed under DRY_RUN, and only for the unambiguous case of a
 * method whose zone is already fully scoped to one channel and is only missing the
 * per-channel listing.
 *
 * Guide: https://www.allanninal.dev/saleor/no-shipping-methods-for-channel/
 */
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";

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

const ZONES_QUERY = `
query {
  shippingZones(first: 100) {
    edges {
      node {
        id name
        channels { id }
        warehouses { id channels { id } }
        shippingMethods { id name channelListings { channel { id } price { amount } } }
      }
    }
  }
}`;

export function findChannelsMissingShippingCoverage(channels, shippingZones) {
  const flagged = [];
  for (const channel of channels) {
    const cid = channel.id;
    const zonesForChannel = shippingZones.filter((z) =>
      (z.channels || []).some((c) => c.id === cid)
    );
    if (zonesForChannel.length === 0) {
      flagged.push({ channelId: cid, reason: "NO_ZONE" });
      continue;
    }

    const hasWarehouse = zonesForChannel.some((z) =>
      (z.warehouses || []).some((wh) => (wh.channels || []).some((c) => c.id === cid))
    );
    if (!hasWarehouse) {
      flagged.push({ channelId: cid, reason: "NO_WAREHOUSE_IN_CHANNEL" });
      continue;
    }

    const hasListedMethod = zonesForChannel.some((z) =>
      (z.shippingMethods || []).some((m) =>
        (m.channelListings || []).some((cl) => cl.channel.id === cid)
      )
    );
    if (!hasListedMethod) {
      flagged.push({ channelId: cid, reason: "NO_METHOD_LISTED" });
    }
  }
  return flagged;
}

export function findUnambiguousRepair(channel, shippingZones) {
  const cid = channel.id;
  const zonesForChannel = shippingZones.filter((z) =>
    (z.channels || []).some((c) => c.id === cid)
  );
  if (zonesForChannel.length !== 1) return null;
  const zone = zonesForChannel[0];
  if ((zone.channels || []).length !== 1) return null;
  for (const method of zone.shippingMethods || []) {
    const hasListing = (method.channelListings || []).some((cl) => cl.channel.id === cid);
    if (!hasListing) {
      return { shippingMethodId: method.id, shippingMethodName: method.name };
    }
  }
  return null;
}

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 fetchChannelsAndZones() {
  const channels = (await gql(CHANNELS_QUERY)).channels;
  const zones = (await gql(ZONES_QUERY)).shippingZones.edges.map((e) => e.node);
  return { channels, zones };
}

function printPlannedListingUpdate(shippingMethodId, channelId, currency) {
  const variables = {
    id: shippingMethodId,
    input: { addChannels: [{ channelId, price: "0.00", currency }] },
  };
  console.log("DRY RUN would call shippingMethodChannelListingUpdate:", JSON.stringify(variables));
}

export async function run() {
  const { channels, zones } = await fetchChannelsAndZones();
  const flagged = findChannelsMissingShippingCoverage(channels, zones);
  const byId = Object.fromEntries(channels.map((c) => [c.id, c]));

  if (flagged.length === 0) {
    console.log("Every channel has at least one usable shipping method.");
    return;
  }

  for (const item of flagged) {
    const channel = byId[item.channelId];
    console.warn(`Channel ${channel.name} (${channel.slug}) has no usable shipping methods: ${item.reason}`);
    if (item.reason === "NO_METHOD_LISTED") {
      const repair = findUnambiguousRepair(channel, zones);
      if (repair) {
        console.log(`Unambiguous repair candidate: method ${repair.shippingMethodName} is missing a listing.`);
        if (DRY_RUN) {
          printPlannedListingUpdate(repair.shippingMethodId, channel.id, channel.currencyCode);
        } else {
          console.warn("DRY_RUN is false, but this script only prints planned repairs. "
            + "Review the printed mutation and apply it by hand or from your own reviewed tooling.");
        }
      }
    }
  }

  console.log(`Done. ${flagged.length} channel(s) flagged.`);
}

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 a channel gets flagged and why. Because we kept find_channels_missing_shipping_coverage pure, the test needs no network and no Saleor store. It just feeds in plain data structures and checks the answer.

test_no_shipping_methods.py
from find_missing_shipping_coverage import find_channels_missing_shipping_coverage

CH_A = {"id": "Q2hhbm5lbDox"}
CH_B = {"id": "Q2hhbm5lbDoy"}


def zone(**over):
    base = {
        "id": "U2hpcHBpbmdab25lOjE=",
        "channels": [{"id": "Q2hhbm5lbDox"}],
        "warehouses": [{"id": "V2FyZWhvdXNlOjE=", "channels": [{"id": "Q2hhbm5lbDox"}]}],
        "shippingMethods": [
            {"id": "U2hpcHBpbmdNZXRob2Q6MQ==", "channelListings": [{"channel": {"id": "Q2hhbm5lbDox"}}]}
        ],
    }
    base.update(over)
    return base


def test_channel_fully_covered_is_not_flagged():
    assert find_channels_missing_shipping_coverage([CH_A], [zone()]) == []


def test_channel_with_no_zone_is_flagged():
    result = find_channels_missing_shipping_coverage([CH_B], [zone()])
    assert result == [{"channelId": "Q2hhbm5lbDoy", "reason": "NO_ZONE"}]


def test_channel_with_zone_but_no_warehouse_is_flagged():
    z = zone(warehouses=[{"id": "V2FyZWhvdXNlOjE=", "channels": []}])
    result = find_channels_missing_shipping_coverage([CH_A], [z])
    assert result == [{"channelId": "Q2hhbm5lbDox", "reason": "NO_WAREHOUSE_IN_CHANNEL"}]


def test_channel_with_zone_and_warehouse_but_no_listed_method_is_flagged():
    z = zone(shippingMethods=[{"id": "U2hpcHBpbmdNZXRob2Q6MQ==", "channelListings": []}])
    result = find_channels_missing_shipping_coverage([CH_A], [z])
    assert result == [{"channelId": "Q2hhbm5lbDox", "reason": "NO_METHOD_LISTED"}]


def test_multiple_channels_only_flags_the_broken_one():
    result = find_channels_missing_shipping_coverage([CH_A, CH_B], [zone()])
    assert result == [{"channelId": "Q2hhbm5lbDoy", "reason": "NO_ZONE"}]


def test_zone_with_no_channels_at_all_flags_every_channel():
    z = zone(channels=[])
    result = find_channels_missing_shipping_coverage([CH_A, CH_B], [z])
    assert result == [
        {"channelId": "Q2hhbm5lbDox", "reason": "NO_ZONE"},
        {"channelId": "Q2hhbm5lbDoy", "reason": "NO_ZONE"},
    ]
shipping-coverage.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findChannelsMissingShippingCoverage } from "./find-missing-shipping-coverage.js";

const CH_A = { id: "Q2hhbm5lbDox" };
const CH_B = { id: "Q2hhbm5lbDoy" };

const zone = (over = {}) => ({
  id: "U2hpcHBpbmdab25lOjE=",
  channels: [{ id: "Q2hhbm5lbDox" }],
  warehouses: [{ id: "V2FyZWhvdXNlOjE=", channels: [{ id: "Q2hhbm5lbDox" }] }],
  shippingMethods: [
    { id: "U2hpcHBpbmdNZXRob2Q6MQ==", channelListings: [{ channel: { id: "Q2hhbm5lbDox" } }] },
  ],
  ...over,
});

test("channel fully covered is not flagged", () => {
  assert.deepEqual(findChannelsMissingShippingCoverage([CH_A], [zone()]), []);
});

test("channel with no zone is flagged", () => {
  const result = findChannelsMissingShippingCoverage([CH_B], [zone()]);
  assert.deepEqual(result, [{ channelId: "Q2hhbm5lbDoy", reason: "NO_ZONE" }]);
});

test("channel with zone but no warehouse is flagged", () => {
  const z = zone({ warehouses: [{ id: "V2FyZWhvdXNlOjE=", channels: [] }] });
  const result = findChannelsMissingShippingCoverage([CH_A], [z]);
  assert.deepEqual(result, [{ channelId: "Q2hhbm5lbDox", reason: "NO_WAREHOUSE_IN_CHANNEL" }]);
});

test("channel with zone and warehouse but no listed method is flagged", () => {
  const z = zone({ shippingMethods: [{ id: "U2hpcHBpbmdNZXRob2Q6MQ==", channelListings: [] }] });
  const result = findChannelsMissingShippingCoverage([CH_A], [z]);
  assert.deepEqual(result, [{ channelId: "Q2hhbm5lbDox", reason: "NO_METHOD_LISTED" }]);
});

test("multiple channels only flags the broken one", () => {
  const result = findChannelsMissingShippingCoverage([CH_A, CH_B], [zone()]);
  assert.deepEqual(result, [{ channelId: "Q2hhbm5lbDoy", reason: "NO_ZONE" }]);
});

test("zone with no channels at all flags every channel", () => {
  const z = zone({ channels: [] });
  const result = findChannelsMissingShippingCoverage([CH_A, CH_B], [z]);
  assert.deepEqual(result, [
    { channelId: "Q2hhbm5lbDox", reason: "NO_ZONE" },
    { channelId: "Q2hhbm5lbDoy", reason: "NO_ZONE" },
  ]);
});

Case studies

New channel launch

The regional storefront that shipped nothing

A store launched a second channel for a new country. The team cloned an existing shipping zone, added the new channel to it with addChannels, and moved on. Every checkout on the new channel showed an empty shipping list, and support assumed the storefront was broken.

Running the classifier showed the real reason in seconds: NO_METHOD_LISTED on every method in the zone. The zone and warehouse links were fine, only the per-channel listings were missing. Once the team ran shippingMethodChannelListingUpdate for each method with the right price for that channel's currency, checkout worked immediately.

Warehouse migration

The warehouse that moved but stayed behind for one channel

A merchant consolidated two warehouses into one during a fulfillment migration. The new warehouse was assigned to the primary channel but the team forgot to add it to a secondary wholesale channel that used the same shipping zone.

The script flagged that channel with NO_WAREHOUSE_IN_CHANNEL even though the zone and every method listing looked correct, which pointed the team straight at the warehouse assignment instead of wasting time re-checking shipping method prices.

What good looks like

After running this on a schedule, every channel with a real shipping gap gets flagged with the exact reason, not a guess. Zone problems get fixed with shippingZoneUpdate, warehouse problems get fixed with a warehouse or channel update, and missing listings get fixed with a reviewed shippingMethodChannelListingUpdate call, each with the correct price for that channel. No customer ever reaches checkout to find silence where a shipping option should be.

FAQ

Why does checkout return zero shipping methods for a channel that has a shipping zone?

A shipping zone covering the country is not enough. Each ShippingMethod inside that zone also needs its own ShippingMethodChannelListing for that specific channel, created with shippingMethodChannelListingUpdate. Without that listing the method exists in the zone but is invisible to that channel's checkouts, and the list comes back empty with no error.

Is a missing shipping zone the same problem as a missing channel listing?

No, they are two different gaps that produce the same empty list. The zone can be linked to the channel and still have methods with no listing for that channel, or the zone itself can be missing the channel link, or none of the zone's warehouses can be assigned to the channel. Each cause needs a different fix, so a script should report which one applies instead of guessing.

Can I safely auto fix a channel with no shipping methods?

Only in the narrow case where a shipping method's zone is already fully scoped to one channel and only the per-channel listing is missing. Price and currency are business decisions, so the safe default is to flag the gap and run any repair mutation in a dry run first, printing the planned shippingMethodChannelListingUpdate call before sending it.

Related field notes

Citations

On the problem:

  1. Shipping zones in GraphQL have a very non-GraphQL interface. Issue #2792, saleor/saleor. github.com/saleor/saleor/issues/2792
  2. checkoutCreate's available shipping methods is not taking the new shipping address. Issue #3986, saleor/saleor. github.com/saleor/saleor/issues/3986
  3. No payment creation prevention without shipping method set. Issue #5444, saleor/saleor. github.com/saleor/saleor/issues/5444

On the solution:

  1. Saleor Docs: the shippingMethodChannelListingUpdate mutation. docs.saleor.io shipping-method-channel-listing-update
  2. Saleor API Reference: the ShippingZone object. docs.saleor.io/api-reference/shipping/objects/shipping-zone
  3. Saleor Developer Docs: Shipping Zone. docs.saleor.io/developer/shipping/shipping-zone

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 find your missing shipping listing?

If this saved you a support thread or a night chasing an empty shipping list, 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