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.
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.
Why it happens
A few common ways a fully valid, visible product ends up missing on a channel:
- A new storefront or channel is created after the catalog was already built out, and channel assignment was a manual, one-time step that nobody repeated for the new channel_id.
- A bulk import or CSV product upload only ever wrote to the default channel, either because that was the only channel that existed at the time or because the import tool never exposed a channel-assignment option at all.
- The Channel Manager flow in the BigCommerce control panel was used once to publish products to the original storefront, and adding a second storefront later did not retroactively re-run that flow against the new channel.
- Category assignment and product visibility were checked and confirmed correct, which is exactly why the channel-assignment gap gets missed. Everything a merchant normally looks at to debug "why isn't this showing" checks out, and the actual gatekeeper is a table nobody thought to check.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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
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;
}
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.
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)
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;
}
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.
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)
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);
}
}
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.
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 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 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.
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) == []
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
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.
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.
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:
- BigCommerce Developer Center: the Channels API reference. developer.bigcommerce.com channels
- BigCommerce Community: how to set products to automatically assign a sales channel. support.bigcommerce.com automatically assign a sales channel
- BigCommerce Community: my newest product isn't showing up. support.bigcommerce.com my newest product isn't showing up
On the solution:
- BigCommerce Developer Center: the Channel Assignments endpoint. developer.bigcommerce.com channel assignments
- BigCommerce Developer Center: the Category Assignments endpoint. developer.bigcommerce.com category assignments
- 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.
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