Reconciler Multi-Storefront / Channels

Product invisible on a channel despite correct category and visibility flags

The product is visible, correctly categorized, and sells fine on your original storefront. On a second or third channel it 404s or is simply absent from listings and search. Category membership and is_visible were never the gatekeeper for a sales channel. A separate table decides whether a channel can see the product at all, and nothing fills it in automatically when you spin up a new storefront. Here is why that gap opens up and a script that finds every product missing from every channel's assignment set.

Python and Node.js BigCommerce V3 Channels and Catalog APIs Safe by default (report only)
Close-up of a keyboard with a prominent 'buy' button.
Photo by Money Knack on Unsplash
The short answer

In BigCommerce's multi-storefront model, category membership and the product's is_visible flag only control whether a product can appear inside a category tree or search index. They say nothing about which sales channel exposes the product at all. A product is only reachable on a given channel if it has an explicit row in the products-channel-assignments table, created with PUT /v3/catalog/products/channel-assignments. New storefronts and channels do not automatically inherit assignments from the default channel, and bulk imports, CSV product uploads, and the default Channel Manager flow can silently skip a newly created channel. Run a small Python or Node.js script that lists every channel with GET /v3/channels, lists every visible catalog product with GET /v3/catalog/products, lists each channel's assigned product ids with GET /v3/catalog/products/channel-assignments, and diffs the sets to report every (product_id, channel_id) gap. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce's multi-storefront model separates two concerns that look, from the admin screen, like the same thing. A product's category tree placement and its is_visible flag decide whether the product can be found and displayed once someone is looking at a given storefront. Whether a storefront can see the product at all is a completely different question, answered by a separate table, products-channel-assignments, and a separate API surface, /v3/catalog/products/channel-assignments.

When a merchant creates a second or third channel, whether that is a new storefront, a marketplace connection, or a headless frontend, BigCommerce does not copy over the default channel's product assignments. Every product that should be reachable on that new channel needs its own explicit assignment row. Bulk imports, CSV uploads, and even the everyday Channel Manager flow in the admin were frequently run once, against whatever channel existed at the time, and nobody went back and ran the channel-assignment call again when the new channel showed up. The result is a product that is fully visible, sitting in the right category, selling normally on the original storefront, and 404ing or simply missing from listings and search on the newer one, with nothing in the product record itself pointing at the real cause.

Product record is_visible: true, category ok Channel 1 assignment row exists, product visible No assignment row Channel 2 assignment no row, never created 404 or absent from listings/search
The same product, same is_visible flag, same category. Channel 1 has a row in products-channel-assignments. Channel 2 never got one, so the product does not exist there as far as that storefront is concerned.

Why it happens

A few common ways a fully valid, visible product ends up missing on a channel:

This is a well known point of confusion in the BigCommerce community: a product looks perfect in the admin, category is right, visibility is on, and it is still nowhere to be found on a second storefront. See the citations at the end for the exact community threads and docs.

The key insight

Category and is_visible answer "can this product be found once you're looking at this storefront." Channel assignment answers "does this storefront even get to look." They are enforced independently, and BigCommerce does not backfill assignments when a new channel is created. So the safe pattern is not "make every product visible everywhere." It is "list what each channel actually has assigned, diff it against the full visible catalog, and report every gap." We optionally cross-check GET /v3/catalog/products/{id}/category-assignments to prove the category placement is genuinely fine, confirming the category and visibility flags are a red herring, before ever touching channel-assignments.

The fix, as a flow

We do not touch the live catalog or push products anywhere by default. We add a job that lists every channel, lists every visible product, lists each channel's assigned product ids, and reports the (product_id, channel_id) pairs that are missing, so a human decides which gaps are real bugs and which are intentional channel-specific catalogs.

List channels GET /v3/channels List visible products GET /v3/catalog/products List per-channel assignments GET .../channel-assignments Missing from channel's set? yes no, already assigned Report gap product_id, channel_id
The script only reports gaps by default. Writing a channel assignment happens only when a human opts in per channel, guarded by a dry run flag.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Products (modify) and Channel Settings (read at minimum) scope so it can read channels, read the catalog, read channel-assignments, and, only if you opt in later, write channel-assignments. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   # start safe, change to false only to write a repair
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false only to write a repair
2

Talk to the V3 Channels and Catalog REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT, raises on a non-2xx response, and unwraps V3's {data, meta.pagination} envelope. We reuse it to list channels, list catalog products, and read channel-assignments.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()

def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

Collect channels, visible products, and per-channel assignments

Call GET /v3/channels, paginated via meta.pagination, to collect every channel's id and type. Call GET /v3/catalog/products?limit=250&include_fields=id,name,is_visible, paginated via meta.pagination.total_pages, keeping only products where is_visible is true. For each channel_id, call GET /v3/catalog/products/channel-assignments?channel_id:in={channel_id}&limit=250, paginated the same way, and collect the set of product_id values it returns.

step3.py
def all_channels():
    page = 1
    while True:
        res = bc_get("/channels", {"page": page, "limit": 250})
        data = res.get("data") or []
        if not data:
            return
        for channel in data:
            yield channel
        if page >= (res.get("meta", {}).get("pagination", {}).get("total_pages") or page):
            return
        page += 1

def visible_catalog_product_ids():
    page = 1
    ids = set()
    while True:
        res = bc_get("/catalog/products", {
            "limit": 250, "page": page,
            "include_fields": "id,name,is_visible",
        })
        data = res.get("data") or []
        if not data:
            break
        for product in data:
            if product.get("is_visible"):
                ids.add(product["id"])
        if page >= (res.get("meta", {}).get("pagination", {}).get("total_pages") or page):
            break
        page += 1
    return ids

def channel_assigned_product_ids(channel_id):
    page = 1
    ids = set()
    while True:
        res = bc_get("/catalog/products/channel-assignments", {
            "channel_id:in": channel_id, "limit": 250, "page": page,
        })
        data = res.get("data") or []
        if not data:
            break
        for row in data:
            ids.add(row["product_id"])
        if page >= (res.get("meta", {}).get("pagination", {}).get("total_pages") or page):
            break
        page += 1
    return ids
step3.js
async function* allChannels() {
  let page = 1;
  while (true) {
    const res = await bcGet("/channels", { page, limit: 250 });
    const data = res.data || [];
    if (!data.length) return;
    for (const channel of data) yield channel;
    const totalPages = res.meta?.pagination?.total_pages || page;
    if (page >= totalPages) return;
    page += 1;
  }
}

async function visibleCatalogProductIds() {
  const ids = new Set();
  let page = 1;
  while (true) {
    const res = await bcGet("/catalog/products", {
      limit: 250, page, include_fields: "id,name,is_visible",
    });
    const data = res.data || [];
    if (!data.length) break;
    for (const product of data) if (product.is_visible) ids.add(product.id);
    const totalPages = res.meta?.pagination?.total_pages || page;
    if (page >= totalPages) break;
    page += 1;
  }
  return ids;
}

async function channelAssignedProductIds(channelId) {
  const ids = new Set();
  let page = 1;
  while (true) {
    const res = await bcGet("/catalog/products/channel-assignments", {
      "channel_id:in": channelId, limit: 250, page,
    });
    const data = res.data || [];
    if (!data.length) break;
    for (const row of data) ids.add(row.product_id);
    const totalPages = res.meta?.pagination?.total_pages || page;
    if (page >= totalPages) break;
    page += 1;
  }
  return ids;
}
4

Decide, with one pure function

Keep the diff in its own function that takes the full set of catalog product ids, a mapping of channel_id to the set of product ids assigned to that channel, and the set of ids where is_visible is true, and returns the sorted list of (product_id, channel_id) gaps. It is pure set-difference logic, no network, fully testable with fixture dicts.

decide.py
def find_missing_channel_assignments(
    catalog_product_ids: set, channel_assignments: dict, visible_ids: set
) -> list:
    gaps = []
    for channel_id, assigned_ids in channel_assignments.items():
        for product_id in catalog_product_ids:
            if product_id in visible_ids and product_id not in assigned_ids:
                gaps.append((product_id, channel_id))
    return sorted(gaps)
decide.js
export function findMissingChannelAssignments(catalogProductIds, channelAssignments, visibleIds) {
  const gaps = [];
  for (const [channelIdStr, assignedIds] of Object.entries(channelAssignments)) {
    const channelId = Number(channelIdStr);
    for (const productId of catalogProductIds) {
      if (visibleIds.has(productId) && !assignedIds.has(productId)) {
        gaps.push([productId, channelId]);
      }
    }
  }
  gaps.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  return gaps;
}
5

Write the report, and only repair when a human opts in

By default the job writes every (product_id, channel_id) gap to a CSV or JSON report and stops there, because a missing assignment can be intentional for a channel-specific catalog. Only when the user explicitly passes --repair-channel=<channel_id> does the job issue PUT /v3/catalog/products/channel-assignments with a JSON array body of {"product_id": <id>, "channel_id": <channel_id>} objects, batched in chunks of about 50 to 100 to stay under payload limits.

apply.py
BATCH_SIZE = 50

def repair_channel_gaps(gaps_for_channel, channel_id):
    """gaps_for_channel: list of product_id. Never call this in parallel for the
    same product_id, per BigCommerce's own guidance against overlapping
    assignment requests."""
    for i in range(0, len(gaps_for_channel), BATCH_SIZE):
        batch = gaps_for_channel[i:i + BATCH_SIZE]
        body = [{"product_id": pid, "channel_id": channel_id} for pid in batch]
        bc_put("/catalog/products/channel-assignments", body)
apply.js
const BATCH_SIZE = 50;

// gapsForChannel: array of product_id. Never call this in parallel for the
// same product_id, per BigCommerce's own guidance against overlapping
// assignment requests.
async function repairChannelGaps(gapsForChannel, channelId) {
  for (let i = 0; i < gapsForChannel.length; i += BATCH_SIZE) {
    const batch = gapsForChannel.slice(i, i + BATCH_SIZE);
    const body = batch.map((pid) => ({ product_id: pid, channel_id: channelId }));
    await bcPut("/catalog/products/channel-assignments", body);
  }
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. With DRY_RUN on, the script only logs the planned {product_id, channel_id} pairs it found, whether or not --repair-channel was passed. Read the report, confirm with the merchant which gaps are real bugs versus intentional channel-specific exclusions, then switch DRY_RUN off only for the channel_id you decided to repair.

Run it safe

Always start with DRY_RUN=true and no --repair-channel flag. Never issue overlapping or parallel PUT requests for the same product_id, per BigCommerce's own guidance. A missing assignment is not automatically a bug, some channels are meant to carry a smaller catalog, so only write once a human has reviewed the report per channel.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, reports every gap it finds, respects the dry run flag, and only writes a channel assignment when a channel_id is explicitly opted in via --repair-channel.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

find_missing_channel_assignments.py
"""Find BigCommerce products that are visible and categorized but missing from
a channel's assignment set.

Category membership and the product's is_visible flag only control whether a
product can appear within a category tree or search index. They say nothing
about which sales channel exposes the product at all. A product is only
reachable on a given channel if it has an explicit row in the
products-channel-assignments table, created with a PUT to
/v3/catalog/products/channel-assignments. New storefronts and channels do not
automatically inherit assignments from the default channel, and bulk imports,
CSV product uploads, and the default Channel Manager flow can silently skip a
newly created channel. This job lists every channel, every visible catalog
product, and every channel's assigned product ids, then reports every
(product_id, channel_id) gap. It is not safe to auto-fix blindly, a missing
assignment can be intentional for a channel-specific catalog, so by default
this only reports. Pass --repair-channel= to write assignments for
that one channel, guarded by DRY_RUN.

Guide: https://www.allanninal.dev/bigcommerce/product-invisible-missing-channel-assignment/
"""
import csv
import json
import logging
import os
import sys

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPORT_PATH = os.environ.get("REPORT_PATH", "channel_assignment_gaps.csv")

BATCH_SIZE = 50

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()


def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def find_missing_channel_assignments(
    catalog_product_ids: set, channel_assignments: dict, visible_ids: set
) -> list:
    """Pure set-difference logic. No network, no side effects.

    catalog_product_ids: the full set of catalog product ids.
    channel_assignments: channel_id -> set of product ids assigned to that
    channel, from /v3/catalog/products/channel-assignments.
    visible_ids: the subset of catalog_product_ids where is_visible is true.

    Returns a sorted list of (product_id, channel_id) pairs for every visible
    product missing from every known channel's assignment set.
    """
    gaps = []
    for channel_id, assigned_ids in channel_assignments.items():
        for product_id in catalog_product_ids:
            if product_id in visible_ids and product_id not in assigned_ids:
                gaps.append((product_id, channel_id))
    return sorted(gaps)


def all_channels():
    page = 1
    while True:
        res = bc_get("/channels", {"page": page, "limit": 250})
        data = res.get("data") or []
        if not data:
            return
        for channel in data:
            yield channel
        total_pages = (res.get("meta", {}).get("pagination", {}).get("total_pages") or page)
        if page >= total_pages:
            return
        page += 1


def visible_catalog_product_ids():
    page = 1
    all_ids = set()
    visible_ids = set()
    while True:
        res = bc_get(
            "/catalog/products",
            {"limit": 250, "page": page, "include_fields": "id,name,is_visible"},
        )
        data = res.get("data") or []
        if not data:
            break
        for product in data:
            all_ids.add(product["id"])
            if product.get("is_visible"):
                visible_ids.add(product["id"])
        total_pages = (res.get("meta", {}).get("pagination", {}).get("total_pages") or page)
        if page >= total_pages:
            break
        page += 1
    return all_ids, visible_ids


def channel_assigned_product_ids(channel_id):
    page = 1
    ids = set()
    while True:
        res = bc_get(
            "/catalog/products/channel-assignments",
            {"channel_id:in": channel_id, "limit": 250, "page": page},
        )
        data = res.get("data") or []
        if not data:
            break
        for row in data:
            ids.add(row["product_id"])
        total_pages = (res.get("meta", {}).get("pagination", {}).get("total_pages") or page)
        if page >= total_pages:
            break
        page += 1
    return ids


def repair_channel_gaps(gaps_for_channel, channel_id):
    """gaps_for_channel: list of product_id. Never call this in parallel for
    the same product_id, per BigCommerce's own guidance against overlapping
    channel-assignment requests."""
    for i in range(0, len(gaps_for_channel), BATCH_SIZE):
        batch = gaps_for_channel[i:i + BATCH_SIZE]
        body = [{"product_id": pid, "channel_id": channel_id} for pid in batch]
        log.info(
            "%s PUT channel-assignments channel_id=%s product_ids=%s",
            "DRY RUN" if DRY_RUN else "WRITING",
            channel_id, [b["product_id"] for b in body],
        )
        if not DRY_RUN:
            bc_put("/catalog/products/channel-assignments", body)


def run():
    repair_channel_id = None
    for arg in sys.argv[1:]:
        if arg.startswith("--repair-channel="):
            repair_channel_id = int(arg.split("=", 1)[1])

    channels = list(all_channels())
    log.info("Found %d channel(s).", len(channels))

    catalog_ids, visible_ids = visible_catalog_product_ids()
    log.info("Found %d catalog product(s), %d visible.", len(catalog_ids), len(visible_ids))

    channel_assignments = {}
    for channel in channels:
        channel_id = channel["id"]
        channel_assignments[channel_id] = channel_assigned_product_ids(channel_id)
        log.info(
            "Channel %s (%s): %d assigned product(s).",
            channel_id, channel.get("type"), len(channel_assignments[channel_id]),
        )

    gaps = find_missing_channel_assignments(catalog_ids, channel_assignments, visible_ids)

    with open(REPORT_PATH, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["product_id", "channel_id"])
        writer.writerows(gaps)
    log.info("Wrote %d gap(s) to %s", len(gaps), REPORT_PATH)
    log.info(json.dumps([{"product_id": p, "channel_id": c} for p, c in gaps[:20]]))

    if repair_channel_id is not None:
        gaps_for_channel = [pid for pid, cid in gaps if cid == repair_channel_id]
        log.info(
            "%s %d product(s) for channel_id=%s",
            "Would repair" if DRY_RUN else "Repairing",
            len(gaps_for_channel), repair_channel_id,
        )
        repair_channel_gaps(gaps_for_channel, repair_channel_id)

    log.info("Done. %d total gap(s) across %d channel(s).", len(gaps), len(channels))


if __name__ == "__main__":
    run()
find-missing-channel-assignments.js
/**
 * Find BigCommerce products that are visible and categorized but missing
 * from a channel's assignment set.
 *
 * Category membership and the product's is_visible flag only control whether
 * a product can appear within a category tree or search index. They say
 * nothing about which sales channel exposes the product at all. A product is
 * only reachable on a given channel if it has an explicit row in the
 * products-channel-assignments table, created with a PUT to
 * /v3/catalog/products/channel-assignments. New storefronts and channels do
 * not automatically inherit assignments from the default channel, and bulk
 * imports, CSV product uploads, and the default Channel Manager flow can
 * silently skip a newly created channel. This job lists every channel, every
 * visible catalog product, and every channel's assigned product ids, then
 * reports every (product_id, channel_id) gap. It is not safe to auto-fix
 * blindly, a missing assignment can be intentional for a channel-specific
 * catalog, so by default this only reports. Pass --repair-channel=
 * to write assignments for that one channel, guarded by DRY_RUN.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/product-invisible-missing-channel-assignment/
 */
import { pathToFileURL } from "node:url";
import { writeFile } from "node:fs/promises";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPORT_PATH = process.env.REPORT_PATH || "channel_assignment_gaps.csv";

const BATCH_SIZE = 50;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/**
 * Pure set-difference logic. No network, no side effects.
 *
 * catalogProductIds: iterable of the full set of catalog product ids.
 * channelAssignments: object keyed by channel_id -> Set of product ids
 * assigned to that channel, from /v3/catalog/products/channel-assignments.
 * visibleIds: Set of the subset of catalogProductIds where is_visible is true.
 *
 * Returns a sorted list of [product_id, channel_id] pairs for every visible
 * product missing from every known channel's assignment set.
 */
export function findMissingChannelAssignments(catalogProductIds, channelAssignments, visibleIds) {
  const gaps = [];
  for (const [channelIdStr, assignedIds] of Object.entries(channelAssignments)) {
    const channelId = Number(channelIdStr);
    for (const productId of catalogProductIds) {
      if (visibleIds.has(productId) && !assignedIds.has(productId)) {
        gaps.push([productId, channelId]);
      }
    }
  }
  gaps.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  return gaps;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* allChannels() {
  let page = 1;
  while (true) {
    const res = await bcGet("/channels", { page, limit: 250 });
    const data = res.data || [];
    if (!data.length) return;
    for (const channel of data) yield channel;
    const totalPages = res.meta?.pagination?.total_pages || page;
    if (page >= totalPages) return;
    page += 1;
  }
}

async function visibleCatalogProductIds() {
  const allIds = new Set();
  const visibleIds = new Set();
  let page = 1;
  while (true) {
    const res = await bcGet("/catalog/products", {
      limit: 250, page, include_fields: "id,name,is_visible",
    });
    const data = res.data || [];
    if (!data.length) break;
    for (const product of data) {
      allIds.add(product.id);
      if (product.is_visible) visibleIds.add(product.id);
    }
    const totalPages = res.meta?.pagination?.total_pages || page;
    if (page >= totalPages) break;
    page += 1;
  }
  return { allIds, visibleIds };
}

async function channelAssignedProductIds(channelId) {
  const ids = new Set();
  let page = 1;
  while (true) {
    const res = await bcGet("/catalog/products/channel-assignments", {
      "channel_id:in": channelId, limit: 250, page,
    });
    const data = res.data || [];
    if (!data.length) break;
    for (const row of data) ids.add(row.product_id);
    const totalPages = res.meta?.pagination?.total_pages || page;
    if (page >= totalPages) break;
    page += 1;
  }
  return ids;
}

// gapsForChannel: array of product_id. Never call this in parallel for the
// same product_id, per BigCommerce's own guidance against overlapping
// channel-assignment requests.
async function repairChannelGaps(gapsForChannel, channelId) {
  for (let i = 0; i < gapsForChannel.length; i += BATCH_SIZE) {
    const batch = gapsForChannel.slice(i, i + BATCH_SIZE);
    const body = batch.map((pid) => ({ product_id: pid, channel_id: channelId }));
    console.log(
      `${DRY_RUN ? "DRY RUN" : "WRITING"} PUT channel-assignments channel_id=${channelId} product_ids=${JSON.stringify(batch)}`
    );
    if (!DRY_RUN) await bcPut("/catalog/products/channel-assignments", body);
  }
}

export async function run() {
  const repairArg = process.argv.find((a) => a.startsWith("--repair-channel="));
  const repairChannelId = repairArg ? Number(repairArg.split("=")[1]) : null;

  const channels = [];
  for await (const channel of allChannels()) channels.push(channel);
  console.log(`Found ${channels.length} channel(s).`);

  const { allIds: catalogIds, visibleIds } = await visibleCatalogProductIds();
  console.log(`Found ${catalogIds.size} catalog product(s), ${visibleIds.size} visible.`);

  const channelAssignments = {};
  for (const channel of channels) {
    channelAssignments[channel.id] = await channelAssignedProductIds(channel.id);
    console.log(`Channel ${channel.id} (${channel.type}): ${channelAssignments[channel.id].size} assigned product(s).`);
  }

  const gaps = findMissingChannelAssignments(catalogIds, channelAssignments, visibleIds);

  const csv = ["product_id,channel_id", ...gaps.map(([p, c]) => `${p},${c}`)].join("\n");
  await writeFile(REPORT_PATH, csv, "utf8");
  console.log(`Wrote ${gaps.length} gap(s) to ${REPORT_PATH}`);
  console.log(JSON.stringify(gaps.slice(0, 20).map(([p, c]) => ({ product_id: p, channel_id: c }))));

  if (repairChannelId !== null) {
    const gapsForChannel = gaps.filter(([, c]) => c === repairChannelId).map(([p]) => p);
    console.log(`${DRY_RUN ? "Would repair" : "Repairing"} ${gapsForChannel.length} product(s) for channel_id=${repairChannelId}`);
    await repairChannelGaps(gapsForChannel, repairChannelId);
  }

  console.log(`Done. ${gaps.length} total gap(s) across ${channels.length} channel(s).`);
}

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

Add a test

The diff rule is the part most worth testing, because it decides which gaps get reported and, eventually, which get repaired. Because find_missing_channel_assignments takes only plain sets and a plain dict, the test needs no network and no BigCommerce store. It just feeds in fixture data and checks the answer.

test_product_channel_gaps.py
from find_missing_channel_assignments import find_missing_channel_assignments


def test_no_gaps_when_every_visible_product_is_assigned():
    catalog_ids = {1, 2}
    visible_ids = {1, 2}
    assignments = {10: {1, 2}, 11: {1, 2}}
    assert find_missing_channel_assignments(catalog_ids, assignments, visible_ids) == []


def test_flags_visible_product_missing_from_one_channel():
    catalog_ids = {1, 2}
    visible_ids = {1, 2}
    assignments = {10: {1, 2}, 11: {1}}
    assert find_missing_channel_assignments(catalog_ids, assignments, visible_ids) == [(2, 11)]


def test_ignores_invisible_products_even_if_missing_everywhere():
    catalog_ids = {1, 2, 3}
    visible_ids = {1, 2}
    assignments = {10: {1, 2}}
    assert find_missing_channel_assignments(catalog_ids, assignments, visible_ids) == []


def test_flags_across_multiple_channels_and_sorts_the_result():
    catalog_ids = {1, 2}
    visible_ids = {1, 2}
    assignments = {20: set(), 10: {1}}
    assert find_missing_channel_assignments(catalog_ids, assignments, visible_ids) == [
        (1, 20), (2, 10), (2, 20),
    ]


def test_no_channels_means_no_gaps():
    catalog_ids = {1, 2}
    visible_ids = {1, 2}
    assert find_missing_channel_assignments(catalog_ids, {}, visible_ids) == []
find-missing-channel-assignments.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findMissingChannelAssignments } from "./find-missing-channel-assignments.js";

test("no gaps when every visible product is assigned", () => {
  const catalogIds = new Set([1, 2]);
  const visibleIds = new Set([1, 2]);
  const assignments = { 10: new Set([1, 2]), 11: new Set([1, 2]) };
  assert.deepEqual(findMissingChannelAssignments(catalogIds, assignments, visibleIds), []);
});

test("flags visible product missing from one channel", () => {
  const catalogIds = new Set([1, 2]);
  const visibleIds = new Set([1, 2]);
  const assignments = { 10: new Set([1, 2]), 11: new Set([1]) };
  assert.deepEqual(findMissingChannelAssignments(catalogIds, assignments, visibleIds), [[2, 11]]);
});

test("ignores invisible products even if missing everywhere", () => {
  const catalogIds = new Set([1, 2, 3]);
  const visibleIds = new Set([1, 2]);
  const assignments = { 10: new Set([1, 2]) };
  assert.deepEqual(findMissingChannelAssignments(catalogIds, assignments, visibleIds), []);
});

test("flags across multiple channels and sorts the result", () => {
  const catalogIds = new Set([1, 2]);
  const visibleIds = new Set([1, 2]);
  const assignments = { 20: new Set(), 10: new Set([1]) };
  assert.deepEqual(findMissingChannelAssignments(catalogIds, assignments, visibleIds), [
    [1, 20], [2, 10], [2, 20],
  ]);
});

test("no channels means no gaps", () => {
  const catalogIds = new Set([1, 2]);
  const visibleIds = new Set([1, 2]);
  assert.deepEqual(findMissingChannelAssignments(catalogIds, {}, visibleIds), []);
});

Case studies

New storefront launch

The brand that launched a second storefront and lost half the catalog

A merchant spun up a second BigCommerce storefront for a regional brand. Categories were rebuilt, products were marked visible, and everything looked ready. Live, roughly half the catalog either 404ed or was simply missing from browse and search. Support checked category assignment and is_visible on every flagged product and found nothing wrong, because there was nothing wrong there.

Running the reconciler against both channel_ids surfaced the real gap in minutes: none of those products had ever been assigned to the new channel_id, because the original catalog build only ever ran against the default channel. Once the report was reviewed, the missing products were assigned to the new channel in batches and every one of them appeared immediately.

Bulk CSV import

The catalog team that imported 4,000 SKUs before the marketplace channel existed

A retailer bulk-imported thousands of products via CSV, long before they connected a marketplace channel through BigCommerce's Channel Manager. Months later, only a fraction of the catalog was actually flowing to the marketplace, and nobody could explain why some products made it and others did not.

The pattern was channel assignment, not the import. Every product imported before the marketplace channel existed had no row for that channel_id at all. The reconciler's report, filtered to that one channel_id, gave the team an exact list to review and assign, instead of re-running the whole import against a channel it was never designed to target.

What good looks like

After this runs, every channel has a clear, current report of which visible, correctly categorized products it is missing, instead of a pile of support tickets that all start with "but it looks fine in the admin." Nothing gets written automatically. A human reviews the report per channel, decides which gaps are genuine oversights versus intentional channel-specific catalogs, and only then opts a channel in to be repaired.

FAQ

Why is my product invisible on one storefront but fine on another?

Category membership and is_visible only control whether a product can appear inside a category tree or search index. They say nothing about which sales channel exposes the product. A product is only reachable on a given channel if it has an explicit row in the products-channel-assignments table, created with PUT to /v3/catalog/products/channel-assignments, and a new storefront does not automatically inherit assignments from the default channel.

Why did a bulk import or CSV upload leave products missing from a new channel?

Bulk imports, CSV product uploads, and the default Channel Manager flow can silently skip a newly created channel because they were run before that channel existed, or because they only ever targeted the default channel. Nobody explicitly called the channel-assignments endpoint for the new channel_id, so the product never got a row there even though its category and visibility flags are correct.

Is it safe to auto-assign every missing product to every channel?

No. A missing assignment can be intentional, for example a channel-specific catalog that should not carry every product. Default to reporting each (product_id, channel_id) gap in a CSV or JSON report. Only write an assignment when the user explicitly opts in per channel with a flag such as --repair-channel, and keep DRY_RUN true until the plan has been reviewed.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: the Channels API reference. developer.bigcommerce.com channels
  2. BigCommerce Community: how to set products to automatically assign a sales channel. support.bigcommerce.com automatically assign a sales channel
  3. BigCommerce Community: my newest product isn't showing up. support.bigcommerce.com my newest product isn't showing up

On the solution:

  1. BigCommerce Developer Center: the Channel Assignments endpoint. developer.bigcommerce.com channel assignments
  2. BigCommerce Developer Center: the Category Assignments endpoint. developer.bigcommerce.com category assignments
  3. BigCommerce Developer Center: the Multi-Storefront API Guide. developer.bigcommerce.com multi-storefront API guide

Stuck on a tricky one?

If you have a problem in BigCommerce channels, catalog, orders, 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 channel gaps?

If this saved you a pile of manual channel-by-channel checking or caught products you would have otherwise missed, 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 BigCommerce field notes