Reconciler Inventory

BigCommerce Inventory API writes are not channel aware

A product can be assigned to one sales channel, several, or none at all. But BigCommerce stores its inventory_level and inventory_warning_level per product or variant at the catalog level, not per channel, and the bulk Inventory API endpoints have no channel_id parameter to scope a write. So a script that pulls a product list for one channel and then bulk-adjusts stock by product_id, variant_id, or sku can silently change quantities for a product that lives on a completely different channel, or no channel at all. Here is why that gap exists and a pre-flight and post-flight reconciler that catches it before, and after, it happens.

Python and Node.js BigCommerce V3 Inventory and Catalog API Safe by default (dry run)
A store shelf filled with lots of different items
Photo by Oxana Melis on Unsplash
The short answer

BigCommerce's Inventory API is not channel aware. POST /v3/inventory/adjustments/absolute and /relative accept batches keyed only by product_id, variant_id, or sku plus location_id, with no channel_id field at all, while channel assignment lives separately in /v3/catalog/products/channel-assignments. A script that resolves its adjustment list from a channel-specific feed can still mutate stock for a product assigned to a different channel, because the Inventory API has no way to know or enforce which channel a write is meant for. The fix is a pre-flight and post-flight guard: fetch the set of product ids actually assigned to the intended channel_id, flag or drop any adjustment row whose resolved product_id is not in that set, and re-diff quantities after the batch runs so any drift outside the intended channel gets rolled back automatically. Full code, tests, and a dry run guard are below.

The problem in plain words

In BigCommerce's catalog data model, a product or variant can be assigned to zero or more sales channels through /v3/catalog/products/channel-assignments. That assignment controls where the product is visible and purchasable, your main storefront, a second storefront, a marketplace channel, and so on. But inventory is not part of that per-channel model. inventory_level and inventory_warning_level live on the product or variant record itself, at the catalog level, shared across every channel that product happens to be assigned to.

The Inventory API mirrors that shape exactly. Its bulk endpoints take rows shaped like {product_id, variant_id, sku, location_id, quantity} and nothing in that shape identifies a channel. BigCommerce's own documentation says it plainly: the Inventory API is not channel aware, and automated or bulk operations you run with it can change stock levels for products that are not even available on the storefront you meant to affect. If a script builds its adjustment list from a channel-specific export, a marketplace feed, or a third-party integration scoped to one channel, and then blindly bulk-adjusts by product_id or sku, it can quietly touch a product that is assigned to some other channel, or to no channel at all.

Channel feed product list, one channel Bulk adjustment keyed by product_id/sku no channel_id field Not channel scoped Catalog-level inventory_level write Wrong channel stock changed
Inventory is stored once per product or variant, shared across every channel it is assigned to. A bulk write keyed only by product_id or sku has no way to stay inside the intended channel's boundary.

Why it happens

BigCommerce splits catalog visibility and catalog inventory into two different systems that were never designed to share a key:

BigCommerce's own developer documentation states this directly: the Inventory API is not channel aware, and automated or bulk operations can change stock levels for products and variants that are not available on the storefront you intend to affect. See the citations at the end for the exact wording and where it lives in the docs.

The key insight

A product_id or sku on your adjustment payload is not proof that the write belongs to the channel you intended. The channel-assignments endpoint is the source of truth for that. So the safe pattern is not "bulk adjust everything the feed gave me." It is "resolve the intended channel's actual assigned product_id set first, then only write the rows that fall inside it, and treat anything outside that set as a flagged, out-of-scope write, never a silent success."

The fix, as a flow

We do not change how the Inventory API itself works, it still has no channel_id. Instead we wrap the existing bulk job with a guard: before any write, resolve the channel's real assigned product ids and flag anything outside that set; after the write, re-diff quantities and roll back any drift found outside the intended channel.

Fetch channel-assignments for the intended channel_id Diff planned batch against assigned ids Row in channel's product set? yes Submit write adjustments/absolute no, flag and skip Post-flight re-diff quantities Drift found? roll back quantity
Only rows whose product_id is confirmed inside the intended channel's assigned set are written. A post-flight diff catches anything that still drifted and rolls it back.

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) scope so it can read channel-assignments and catalog products, plus the scope required for Inventory adjustments. 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 TARGET_CHANNEL_ID="1"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export TARGET_CHANNEL_ID="1"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V3 Catalog and Inventory 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 POST, unwraps the data envelope, and raises on a non-2xx response. We reuse it to read channel-assignments, read products with variants, and submit inventory adjustments.

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()
    body = r.json() if r.text else {}
    return body.get("data", []), body.get("meta", {})

def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}
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}`);
  const text = await res.text();
  const body = text ? JSON.parse(text) : {};
  return [body.data || [], body.meta || {}];
}

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

Resolve the intended channel's assigned product ids

Call GET /v3/catalog/products/channel-assignments?channel_id={channel_id}&limit=250, paginated through meta.pagination.links.next, and collect every product_id assigned to that channel into a set. This set is the boundary the rest of the job checks against. Also build a variant_id -> product_id map from GET /v3/catalog/products?include=variants so rows that only carry a variant_id or sku can still be resolved back to a product_id.

step3.py
def channel_assigned_product_ids(channel_id):
    ids = set()
    page = 1
    while True:
        rows, meta = bc_get("/catalog/products/channel-assignments", {
            "channel_id": channel_id,
            "limit": 250,
            "page": page,
        })
        if not rows:
            return ids
        for row in rows:
            ids.add(row["product_id"])
        pagination = meta.get("pagination", {})
        if not pagination.get("links", {}).get("next"):
            return ids
        page += 1

def variant_product_map(product_ids):
    mapping = {}
    rows, _ = bc_get("/catalog/products", {
        "id:in": ",".join(str(p) for p in product_ids),
        "include": "variants",
        "limit": 250,
    })
    for product in rows:
        for variant in product.get("variants", []):
            mapping[variant["id"]] = product["id"]
    return mapping
step3.js
async function channelAssignedProductIds(channelId) {
  const ids = new Set();
  let page = 1;
  while (true) {
    const [rows, meta] = await bcGet("/catalog/products/channel-assignments", {
      channel_id: channelId,
      limit: 250,
      page,
    });
    if (!rows.length) return ids;
    for (const row of rows) ids.add(row.product_id);
    const next = meta.pagination && meta.pagination.links && meta.pagination.links.next;
    if (!next) return ids;
    page += 1;
  }
}

async function variantProductMap(productIds) {
  const mapping = {};
  const [rows] = await bcGet("/catalog/products", {
    "id:in": productIds.join(","),
    include: "variants",
    limit: 250,
  });
  for (const product of rows) {
    for (const variant of product.variants || []) {
      mapping[variant.id] = product.id;
    }
  }
  return mapping;
}
4

Decide, with one pure function

Keep the out-of-channel check in its own function that takes the channel's assigned product id set, the planned adjustment rows, and the variant-to-product map, and returns the subset of rows that would write outside the intended channel. No network, no side effects, so it is fully unit-testable with synthetic ids.

decide.py
def find_out_of_channel_writes(channel_assigned_product_ids, adjustment_items, product_id_by_variant):
    flagged = []
    for item in adjustment_items:
        pid = item.get("product_id")
        if pid is None and "variant_id" in item:
            pid = product_id_by_variant.get(item["variant_id"])
        if pid is None:
            flagged.append({**item, "_reason": "unresolved_product_id"})
            continue
        if pid not in channel_assigned_product_ids:
            flagged.append({**item, "_reason": "product_not_assigned_to_target_channel", "product_id": pid})
    return flagged
decide.js
export function findOutOfChannelWrites(channelAssignedProductIds, adjustmentItems, productIdByVariant) {
  const flagged = [];
  for (const item of adjustmentItems) {
    let pid = item.product_id;
    if (pid === undefined || pid === null) {
      if ("variant_id" in item) pid = productIdByVariant[item.variant_id];
    }
    if (pid === undefined || pid === null) {
      flagged.push({ ...item, _reason: "unresolved_product_id" });
      continue;
    }
    if (!channelAssignedProductIds.has(pid)) {
      flagged.push({ ...item, _reason: "product_not_assigned_to_target_channel", product_id: pid });
    }
  }
  return flagged;
}
5

Submit only the filtered, in-channel rows

Only the adjustment items whose resolved product_id was confirmed inside channel_assigned_product_ids get sent to POST /v3/inventory/adjustments/absolute (or /relative), batched by location_id, respecting the 2000-item payload limit. If any items were flagged and DRY_RUN is true or a strict flag is set, the whole batch is aborted first, and the flagged rows are printed for a human to review, before anything is ever written.

apply.py
def submit_adjustments(reason, items):
    return bc_post("/inventory/adjustments/absolute", {
        "reason": reason,
        "items": items,
    })
apply.js
async function submitAdjustments(reason, items) {
  return bcPost("/inventory/adjustments/absolute", { reason, items });
}
6

Post-flight reconcile and self-heal any drift

After the write, re-fetch GET /v3/catalog/products?id:in={touched_ids}&include=variants and diff inventory_level against a pre-adjustment snapshot taken before the batch ran. If any product outside the intended channel shows a changed quantity, immediately re-apply its pre-adjustment value with a follow-up POST /v3/inventory/adjustments/absolute call, log the channel_id, product_id, location_id, and old and new quantity, and gate this rollback write behind DRY_RUN=false the same as the primary batch.

Run it safe

Always start with DRY_RUN=true. On a dry run, the pre-flight abort and the rollback both only log what they would do, they never call the Inventory API. Only switch to DRY_RUN=false once you have reviewed a run's flagged rows and are confident the channel-assigned product set is correct.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever writes rows confirmed inside the intended channel, and rolls back anything it finds drifted outside it.

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

channel_scoped_inventory.py
"""Keep BigCommerce bulk inventory adjustments inside the intended sales channel.

BigCommerce's Inventory API is not channel aware. inventory_level and
inventory_warning_level live on the product or variant at the catalog level,
shared across every sales channel that product is assigned to, while
POST /v3/inventory/adjustments/absolute and /relative accept batches keyed
only by product_id, variant_id, or sku plus location_id, with no channel_id
field anywhere in the payload. A bulk job built from a channel-specific feed
can therefore silently mutate stock for a product assigned to a different
channel, or no channel at all. This script wraps a planned adjustment batch
with a pre-flight guard (resolve the target channel's real assigned product
ids and flag or abort on anything outside that set), a filtered write (only
submit the confirmed in-channel rows), and a post-flight reconciler (re-diff
quantities afterward and roll back any drift found outside the channel).
Safe to run again and again. Everything is DRY_RUN-guarded end to end.

Guide: https://www.allanninal.dev/bigcommerce/inventory-api-not-channel-aware/
"""
import os
import logging

import requests

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

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"
TARGET_CHANNEL_ID = int(os.environ.get("TARGET_CHANNEL_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
STRICT = os.environ.get("STRICT", "true").lower() == "true"

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()
    body = r.json() if r.text else {}
    return body.get("data", []), body.get("meta", {})


def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}


def find_out_of_channel_writes(channel_assigned_product_ids: set, adjustment_items: list, product_id_by_variant: dict) -> list:
    """Pure decision. No network, no side effects.

    channel_assigned_product_ids: set of product_id values returned by
        GET /v3/catalog/products/channel-assignments?channel_id={id} for the
        intended channel.
    adjustment_items: the payload rows about to be (or already) sent to
        POST /v3/inventory/adjustments/{absolute|relative}, each a dict with
        keys among {product_id, variant_id, sku, location_id, quantity, reason}.
    product_id_by_variant: precomputed map of variant_id -> product_id (from
        GET /v3/catalog/products?include=variants) used to resolve items that
        only specify variant_id or sku.

    Returns the subset of adjustment_items whose resolved product_id is NOT
    a member of channel_assigned_product_ids, i.e. rows that would silently
    write to inventory outside the intended sales channel. Empty list means
    the whole batch is safe.
    """
    flagged = []
    for item in adjustment_items:
        pid = item.get("product_id")
        if pid is None and "variant_id" in item:
            pid = product_id_by_variant.get(item["variant_id"])
        if pid is None:
            flagged.append({**item, "_reason": "unresolved_product_id"})
            continue
        if pid not in channel_assigned_product_ids:
            flagged.append({**item, "_reason": "product_not_assigned_to_target_channel", "product_id": pid})
    return flagged


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


def variant_product_map(product_ids):
    if not product_ids:
        return {}
    mapping = {}
    rows, _ = bc_get(
        "/catalog/products",
        {"id:in": ",".join(str(p) for p in product_ids), "include": "variants", "limit": 250},
    )
    for product in rows:
        for variant in product.get("variants", []):
            mapping[variant["id"]] = product["id"]
    return mapping


def snapshot_quantities(product_ids):
    if not product_ids:
        return {}
    rows, _ = bc_get(
        "/catalog/products",
        {"id:in": ",".join(str(p) for p in product_ids), "include": "variants", "limit": 250},
    )
    snapshot = {}
    for product in rows:
        snapshot[product["id"]] = product.get("inventory_level")
        for variant in product.get("variants", []):
            snapshot[("variant", variant["id"])] = variant.get("inventory_level")
    return snapshot


def submit_adjustments(reason, items):
    return bc_post("/inventory/adjustments/absolute", {"reason": reason, "items": items})


def run(planned_items):
    """planned_items: the batch a bulk job intends to submit, shaped like
    [{"product_id"|"variant_id"|"sku": ..., "location_id": 1, "quantity": N}, ...]
    """
    resolved_ids = {i["product_id"] for i in planned_items if i.get("product_id") is not None}
    variant_ids = [i["variant_id"] for i in planned_items if i.get("variant_id") is not None]

    assigned_ids = channel_assigned_product_ids(TARGET_CHANNEL_ID)
    variant_map = variant_product_map(resolved_ids | set(variant_ids)) if variant_ids else {}

    flagged = find_out_of_channel_writes(assigned_ids, planned_items, variant_map)

    if flagged:
        log.warning("%d item(s) flagged as out of channel_id=%s scope:", len(flagged), TARGET_CHANNEL_ID)
        for row in flagged:
            log.warning("  %s", row)
        if STRICT:
            log.error("STRICT mode: aborting the whole batch. No writes were made.")
            return

    safe_items = [item for item in planned_items if item not in flagged]
    if not safe_items:
        log.info("Nothing left to write after filtering out-of-channel rows.")
        return

    touched_product_ids = {item.get("product_id") for item in safe_items if item.get("product_id")}
    touched_product_ids |= {variant_map.get(i.get("variant_id")) for i in safe_items if i.get("variant_id")}
    touched_product_ids.discard(None)

    pre_snapshot = snapshot_quantities(touched_product_ids)

    log.info("%d item(s) confirmed in channel_id=%s, %s", len(safe_items), TARGET_CHANNEL_ID,
              "would submit (dry run)" if DRY_RUN else "submitting")
    if not DRY_RUN:
        submit_adjustments("channel_scoped_inventory reconciler", safe_items)

    if DRY_RUN:
        return

    post_snapshot = snapshot_quantities(touched_product_ids)
    for key, pre_value in pre_snapshot.items():
        post_value = post_snapshot.get(key)
        if isinstance(key, tuple):
            continue
        if key not in assigned_ids and post_value != pre_value:
            log.error(
                "Drift outside channel_id=%s: product_id=%s old=%s new=%s. Rolling back.",
                TARGET_CHANNEL_ID, key, pre_value, post_value,
            )
            submit_adjustments(
                "channel_scoped_inventory rollback",
                [{"product_id": key, "location_id": safe_items[0].get("location_id", 1), "quantity": pre_value}],
            )


if __name__ == "__main__":
    run([])
channel-scoped-inventory.js
/**
 * Keep BigCommerce bulk inventory adjustments inside the intended sales channel.
 *
 * BigCommerce's Inventory API is not channel aware. inventory_level and
 * inventory_warning_level live on the product or variant at the catalog level,
 * shared across every sales channel that product is assigned to, while
 * POST /v3/inventory/adjustments/absolute and /relative accept batches keyed
 * only by product_id, variant_id, or sku plus location_id, with no channel_id
 * field anywhere in the payload. A bulk job built from a channel-specific feed
 * can therefore silently mutate stock for a product assigned to a different
 * channel, or no channel at all. This script wraps a planned adjustment batch
 * with a pre-flight guard (resolve the target channel's real assigned product
 * ids and flag or abort on anything outside that set), a filtered write (only
 * submit the confirmed in-channel rows), and a post-flight reconciler (re-diff
 * quantities afterward and roll back any drift found outside the channel).
 * Safe to run again and again. Everything is DRY_RUN-guarded end to end.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/inventory-api-not-channel-aware/
 */
import { pathToFileURL } from "node:url";

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 TARGET_CHANNEL_ID = Number(process.env.TARGET_CHANNEL_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const STRICT = (process.env.STRICT || "true").toLowerCase() === "true";

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

/**
 * Pure decision. No network, no side effects.
 *
 * channelAssignedProductIds: Set of product_id values returned by
 *   GET /v3/catalog/products/channel-assignments?channel_id={id} for the
 *   intended channel.
 * adjustmentItems: the payload rows about to be (or already) sent to
 *   POST /v3/inventory/adjustments/{absolute|relative}, each an object with
 *   keys among {product_id, variant_id, sku, location_id, quantity, reason}.
 * productIdByVariant: precomputed map of variant_id -> product_id (from
 *   GET /v3/catalog/products?include=variants) used to resolve items that
 *   only specify variant_id or sku.
 *
 * Returns the subset of adjustmentItems whose resolved product_id is NOT
 * a member of channelAssignedProductIds, i.e. rows that would silently
 * write to inventory outside the intended sales channel. Empty array means
 * the whole batch is safe.
 */
export function findOutOfChannelWrites(channelAssignedProductIds, adjustmentItems, productIdByVariant) {
  const flagged = [];
  for (const item of adjustmentItems) {
    let pid = item.product_id;
    if (pid === undefined || pid === null) {
      if ("variant_id" in item) pid = productIdByVariant[item.variant_id];
    }
    if (pid === undefined || pid === null) {
      flagged.push({ ...item, _reason: "unresolved_product_id" });
      continue;
    }
    if (!channelAssignedProductIds.has(pid)) {
      flagged.push({ ...item, _reason: "product_not_assigned_to_target_channel", product_id: pid });
    }
  }
  return flagged;
}

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}`);
  const text = await res.text();
  const body = text ? JSON.parse(text) : {};
  return [body.data || [], body.meta || {}];
}

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

async function channelAssignedProductIds(channelId) {
  const ids = new Set();
  let page = 1;
  while (true) {
    const [rows, meta] = await bcGet("/catalog/products/channel-assignments", {
      channel_id: channelId,
      limit: 250,
      page,
    });
    if (!rows.length) return ids;
    for (const row of rows) ids.add(row.product_id);
    const next = meta.pagination && meta.pagination.links && meta.pagination.links.next;
    if (!next) return ids;
    page += 1;
  }
}

async function variantProductMap(productIds) {
  if (!productIds.length) return {};
  const mapping = {};
  const [rows] = await bcGet("/catalog/products", {
    "id:in": productIds.join(","),
    include: "variants",
    limit: 250,
  });
  for (const product of rows) {
    for (const variant of product.variants || []) {
      mapping[variant.id] = product.id;
    }
  }
  return mapping;
}

async function snapshotQuantities(productIds) {
  if (!productIds.length) return {};
  const [rows] = await bcGet("/catalog/products", {
    "id:in": productIds.join(","),
    include: "variants",
    limit: 250,
  });
  const snapshot = {};
  for (const product of rows) {
    snapshot[product.id] = product.inventory_level;
  }
  return snapshot;
}

async function submitAdjustments(reason, items) {
  return bcPost("/inventory/adjustments/absolute", { reason, items });
}

export async function run(plannedItems) {
  const resolvedIds = new Set(plannedItems.filter((i) => i.product_id != null).map((i) => i.product_id));
  const variantIds = plannedItems.filter((i) => i.variant_id != null).map((i) => i.variant_id);

  const assignedIds = await channelAssignedProductIds(TARGET_CHANNEL_ID);
  const variantMap = variantIds.length ? await variantProductMap([...resolvedIds, ...variantIds]) : {};

  const flagged = findOutOfChannelWrites(assignedIds, plannedItems, variantMap);

  if (flagged.length) {
    console.warn(`${flagged.length} item(s) flagged as out of channel_id=${TARGET_CHANNEL_ID} scope:`);
    for (const row of flagged) console.warn(" ", row);
    if (STRICT) {
      console.error("STRICT mode: aborting the whole batch. No writes were made.");
      return;
    }
  }

  const safeItems = plannedItems.filter((item) => !flagged.includes(item));
  if (!safeItems.length) {
    console.log("Nothing left to write after filtering out-of-channel rows.");
    return;
  }

  const touchedProductIds = new Set();
  for (const item of safeItems) {
    if (item.product_id != null) touchedProductIds.add(item.product_id);
    else if (item.variant_id != null && variantMap[item.variant_id] != null) touchedProductIds.add(variantMap[item.variant_id]);
  }

  const preSnapshot = await snapshotQuantities([...touchedProductIds]);

  console.log(
    `${safeItems.length} item(s) confirmed in channel_id=${TARGET_CHANNEL_ID}, ` +
    `${DRY_RUN ? "would submit (dry run)" : "submitting"}`
  );
  if (!DRY_RUN) await submitAdjustments("channel_scoped_inventory reconciler", safeItems);

  if (DRY_RUN) return;

  const postSnapshot = await snapshotQuantities([...touchedProductIds]);
  for (const [key, preValue] of Object.entries(preSnapshot)) {
    const productId = Number(key);
    const postValue = postSnapshot[key];
    if (!assignedIds.has(productId) && postValue !== preValue) {
      console.error(
        `Drift outside channel_id=${TARGET_CHANNEL_ID}: product_id=${productId} old=${preValue} new=${postValue}. Rolling back.`
      );
      await submitAdjustments("channel_scoped_inventory rollback", [
        { product_id: productId, location_id: safeItems[0].location_id || 1, quantity: preValue },
      ]);
    }
  }
}

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

Add a test

The out-of-channel check is the part most worth testing, because it decides whether a real write is allowed to reach the Inventory API. Because find_out_of_channel_writes takes only plain values and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in synthetic ids and checks the answer.

test_inventory_channel_scope.py
from channel_scoped_inventory import find_out_of_channel_writes


def test_empty_list_when_all_products_are_in_channel():
    assigned = {101, 102}
    items = [{"product_id": 101, "location_id": 1, "quantity": 5}]
    assert find_out_of_channel_writes(assigned, items, {}) == []


def test_flags_product_not_in_channel():
    assigned = {101}
    items = [{"product_id": 202, "location_id": 1, "quantity": 5}]
    flagged = find_out_of_channel_writes(assigned, items, {})
    assert len(flagged) == 1
    assert flagged[0]["_reason"] == "product_not_assigned_to_target_channel"
    assert flagged[0]["product_id"] == 202


def test_resolves_variant_id_through_the_map():
    assigned = {101}
    items = [{"variant_id": 555, "location_id": 1, "quantity": 5}]
    flagged = find_out_of_channel_writes(assigned, items, {555: 101})
    assert flagged == []


def test_flags_variant_that_resolves_to_a_product_outside_the_channel():
    assigned = {101}
    items = [{"variant_id": 555, "location_id": 1, "quantity": 5}]
    flagged = find_out_of_channel_writes(assigned, items, {555: 999})
    assert len(flagged) == 1
    assert flagged[0]["product_id"] == 999


def test_flags_unresolved_product_id_when_variant_map_is_missing_it():
    assigned = {101}
    items = [{"variant_id": 555, "location_id": 1, "quantity": 5}]
    flagged = find_out_of_channel_writes(assigned, items, {})
    assert flagged[0]["_reason"] == "unresolved_product_id"


def test_mixed_batch_only_flags_the_out_of_channel_rows():
    assigned = {101, 102}
    items = [
        {"product_id": 101, "location_id": 1, "quantity": 5},
        {"product_id": 999, "location_id": 1, "quantity": 3},
        {"product_id": 102, "location_id": 1, "quantity": 7},
    ]
    flagged = find_out_of_channel_writes(assigned, items, {})
    assert len(flagged) == 1
    assert flagged[0]["product_id"] == 999
channel-scoped-inventory.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOutOfChannelWrites } from "./channel-scoped-inventory.js";

test("empty list when all products are in channel", () => {
  const assigned = new Set([101, 102]);
  const items = [{ product_id: 101, location_id: 1, quantity: 5 }];
  assert.deepEqual(findOutOfChannelWrites(assigned, items, {}), []);
});

test("flags product not in channel", () => {
  const assigned = new Set([101]);
  const items = [{ product_id: 202, location_id: 1, quantity: 5 }];
  const flagged = findOutOfChannelWrites(assigned, items, {});
  assert.equal(flagged.length, 1);
  assert.equal(flagged[0]._reason, "product_not_assigned_to_target_channel");
  assert.equal(flagged[0].product_id, 202);
});

test("resolves variant_id through the map", () => {
  const assigned = new Set([101]);
  const items = [{ variant_id: 555, location_id: 1, quantity: 5 }];
  const flagged = findOutOfChannelWrites(assigned, items, { 555: 101 });
  assert.deepEqual(flagged, []);
});

test("flags variant that resolves to a product outside the channel", () => {
  const assigned = new Set([101]);
  const items = [{ variant_id: 555, location_id: 1, quantity: 5 }];
  const flagged = findOutOfChannelWrites(assigned, items, { 555: 999 });
  assert.equal(flagged.length, 1);
  assert.equal(flagged[0].product_id, 999);
});

test("flags unresolved product id when variant map is missing it", () => {
  const assigned = new Set([101]);
  const items = [{ variant_id: 555, location_id: 1, quantity: 5 }];
  const flagged = findOutOfChannelWrites(assigned, items, {});
  assert.equal(flagged[0]._reason, "unresolved_product_id");
});

test("mixed batch only flags the out-of-channel rows", () => {
  const assigned = new Set([101, 102]);
  const items = [
    { product_id: 101, location_id: 1, quantity: 5 },
    { product_id: 999, location_id: 1, quantity: 3 },
    { product_id: 102, location_id: 1, quantity: 7 },
  ];
  const flagged = findOutOfChannelWrites(assigned, items, {});
  assert.equal(flagged.length, 1);
  assert.equal(flagged[0].product_id, 999);
});

Case studies

Marketplace feed

The store whose marketplace sync clobbered its main storefront stock

A merchant ran a nightly sync that pulled a product feed scoped to a single marketplace channel and bulk-adjusted inventory by SKU for every row in that feed. Several SKUs were reused across the marketplace channel and the primary storefront's own catalog, so the same sync quietly overwrote main-storefront stock levels with numbers meant only for the marketplace.

Adding the pre-flight guard caught it on the very first dry run. The channel-assignments diff flagged every SKU whose resolved product_id was not actually assigned to the marketplace channel_id, and the merchant could see exactly which rows to exclude before a single write happened.

Shared SKU across channels

The catalog where one SKU lived on three channels at once

A product was intentionally assigned to a main storefront, a secondary storefront, and a marketplace channel, all sharing the same underlying inventory_level. A channel-specific stock adjustment intended only for the marketplace channel was, correctly, still going to affect the other two channels too, because BigCommerce's inventory really is shared across every assignment.

The reconciler did not block this write, since the product genuinely was assigned to the target channel, but it did make the shared-inventory behavior visible in the logs, so the team could decide, with full information, whether that shared quantity was actually what they wanted.

What good looks like

After this runs in front of every bulk inventory job, a write can only reach the Inventory API once its product_id has been confirmed inside the intended channel's assigned set. Anything unresolved or out of scope is flagged and, by default, blocks the whole batch instead of writing quietly. And if a drift somehow still lands outside the intended channel, the post-flight diff catches it and writes the pre-adjustment quantity straight back, with a full audit log of channel_id, product_id, location_id, and old and new quantity.

FAQ

Why does BigCommerce's Inventory API affect products outside my target channel?

Inventory levels are stored per product or variant at the catalog level, not per sales channel. The bulk adjustment endpoints only accept product_id, variant_id, or sku plus location_id, with no channel_id parameter at all, so a script that resolves its input list from a channel-specific export can still write to any product that shares that id or sku, regardless of which channels it is actually assigned to.

Can I scope an inventory adjustment to a single sales channel?

No. POST /v3/inventory/adjustments/absolute and /relative have no channel scoping concept. The only way to keep a bulk job from touching the wrong channel's products is to build your own guard: fetch the set of product_id values assigned to the intended channel from GET /v3/catalog/products/channel-assignments, and filter or reject any adjustment row whose product_id is not in that set before you call the Inventory API.

What should happen if an out-of-channel write already happened?

Take a pre-adjustment snapshot of inventory_level for every touched product before the batch runs, then re-fetch quantities afterward and diff. Any product outside the intended channel whose quantity changed should have its pre-adjustment value written back immediately with a follow-up call to POST /v3/inventory/adjustments/absolute, logged with the channel_id, product_id, location_id, and old and new quantity so the drift is auditable.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: the Inventory API is not channel aware. developer.bigcommerce.com inventory adjustments
  2. BigCommerce Developer Center: the Inventory API reference. developer.bigcommerce.com inventory
  3. BigCommerce Docs: inventory adjustments, payload shape, and caveats. developer.bigcommerce.com inventory adjustments (store operations)

On the solution:

  1. BigCommerce Developer Center: absolute and relative adjustment endpoints, batching, and the channel-awareness caveat. developer.bigcommerce.com inventory adjustments
  2. BigCommerce Developer Center: the channel-assignments endpoint, product_id and channel_id filters. developer.bigcommerce.com channel assignments
  3. BigCommerce Docs: inventory adjustments payload shape, reason, items, location_id, variant_id, quantity. developer.bigcommerce.com inventory adjustments (store operations)

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, 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 save your channel's stock levels?

If this caught a bulk job that would have quietly mutated stock on the wrong channel, 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