Repair Inventory and catalog

Variant inventory not tracked

A product has real size or color options built through the control panel or a bulk import. Each variant even shows a stock number in the admin. But orders keep going through for a SKU that should have been out of stock weeks ago. The count never moves. Here is why BigCommerce quietly ignores a variant's inventory and a small script that finds the affected products and flips the one setting that fixes it, only when it is safe to do so.

Python and Node.js V3 Catalog REST Safe by default (dry run)
The short answer

BigCommerce tracks inventory with one setting on the parent product, inventory_tracking, which can be "none", "product", or "variant". It is independent of whether the product actually has variants. When a product has real SKU-level options but inventory_tracking is left at "none" or set to "product", checkout never reads or decrements each variant's own inventory_level, so that SKU can be sold forever no matter what the admin displays. Run a small Python or Node.js script that scans every product, flags the ones with more than one variant whose tracking is not "variant", and repairs only the ones where every affected variant already has a real starting stock count. Full code, tests, and a dry run guard are below.

The problem in plain words

In BigCommerce, having variants and tracking inventory are two separate decisions. A product can have a Small, Medium, and Large, each with its own SKU and its own inventory_level, while the parent product's inventory_tracking field still says "none". Nothing about creating a variant changes that field automatically.

Merchants usually set this up through the storefront control panel or a bulk CSV import, and the "Track inventory" toggle there is not obviously scoped to the variant level. It is easy to leave it off, or to turn on tracking for the product as a whole without going one level deeper to variant tracking. Either way, BigCommerce's checkout and order pipeline never looks at the per-SKU number in that state. The admin can show three units left on a Small, and the store will happily sell the fourth, the fortieth, and the four hundredth.

Product has variants Small, Medium, Large Each has inventory_level shown in the admin tracking not "variant" inventory_tracking "none" or "product" Sells past zero stock
Each variant shows a stock number, but checkout never reads it because tracking on the parent product is not set to the variant level.

Why it happens

The inventory_tracking field is a tri-state setting on the parent product, completely separate from whether the product has options. A few common ways stores end up with it misconfigured:

This is a classic phantom stock bug. The number in the admin looks correct and even updates from imports, but BigCommerce's order pipeline is not reading it, so the store oversells and only finds out from angry backorder emails. See the citations at the end for the exact support articles and community threads.

The key insight

Flipping inventory_tracking to "variant" makes BigCommerce start enforcing whatever inventory_level already sits on each variant record right now. That is only safe if those numbers are real. If a variant's inventory_level is null or was never set, turning tracking on would suddenly show it as zero in stock and block every sale of that SKU. So the fix checks for that before writing anything, and downgrades unsafe cases to a flag for a human instead of an automatic repair.

The fix, as a flow

We do not touch stock counts. We add a job that scans every product, decides which ones genuinely have variant-level SKUs with tracking turned off or set to the wrong level, and separates them into two piles: the ones where every variant already has a real stock number, which get one safe field flipped, and the ones with a missing count, which only get flagged for a human to fill in first.

Scan products include=variants Classify each product variants > 1, tracking != variant Needs fix? no, skip yes All variants have stock? yes no, flag for a human Flagged, no write human sets stock first PUT products/{id} inventory_tracking=variant
The script only flips inventory_tracking to variant when every affected SKU already has a real stock count. A missing count gets flagged, never a false zero.

Build it step by step

1

Get a store hash and an API account token

In your BigCommerce control panel, go to Settings, API, Store-level API accounts, and create an account with the Products scope set to modify. Note the store hash from your control panel URL and the access token from the API account. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST API

Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET and PUT, raises on a non-2xx response, and unwraps the V3 {"data": ...} envelope so callers get plain objects back.

step2.py
import os, requests

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

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}
3

Scan every product with its variants

Call GET /v3/catalog/products?include=variants&limit=250 and page through the whole catalog. Each product comes back with its own inventory_tracking and a variants array, and each variant already carries its own id, sku, and inventory_level. This step never writes anything, it just reads.

step3.py
def all_products():
    page = 1
    limit = 250
    while True:
        batch = bc("GET", f"/v3/catalog/products?include=variants&limit={limit}&page={page}")
        if not batch:
            return
        for product in batch:
            yield product
        if len(batch) < limit:
            return
        page += 1
step3.js
async function* allProducts() {
  let page = 1;
  const limit = 250;
  while (true) {
    const batch = await bc("GET", `/v3/catalog/products?include=variants&limit=${limit}&page=${page}`);
    if (!batch || !batch.length) return;
    for (const product of batch) yield product;
    if (batch.length < limit) return;
    page += 1;
  }
}
4

Classify, with one pure function

Keep the decision in its own function that takes a product with its variants and returns whether it needs a fix, why, and which variant ids are affected. A product with one or zero variants is left alone, since a single default variant is expected even for simple products. A product already on "variant" tracking is already correct. Everything else, a product with real options and tracking at "none" or "product", needs a fix.

decide.py
def classify_variant_tracking(product):
    variants = product.get("variants") or []
    if len(variants) <= 1:
        return {"productId": product["id"], "needsFix": False, "reason": None, "affectedVariantIds": []}

    if product.get("inventory_tracking") == "variant":
        return {"productId": product["id"], "needsFix": False, "reason": None, "affectedVariantIds": []}

    reason = (
        "tracking_disabled_entirely"
        if product.get("inventory_tracking") == "none"
        else "tracking_set_to_product_level_not_variant"
    )
    return {
        "productId": product["id"],
        "needsFix": True,
        "reason": reason,
        "affectedVariantIds": [v["id"] for v in variants],
    }
decide.js
export function classifyVariantTracking(product) {
  const variants = product.variants || [];
  if (variants.length <= 1) {
    return { productId: product.id, needsFix: false, reason: null, affectedVariantIds: [] };
  }

  if (product.inventory_tracking === "variant") {
    return { productId: product.id, needsFix: false, reason: null, affectedVariantIds: [] };
  }

  const reason =
    product.inventory_tracking === "none"
      ? "tracking_disabled_entirely"
      : "tracking_set_to_product_level_not_variant";

  return {
    productId: product.id,
    needsFix: true,
    reason,
    affectedVariantIds: variants.map((v) => v.id),
  };
}
5

Guard the write: only repair when every variant has real stock

Before touching a flagged product, check whether every affected variant already has a non-null inventory_level. If so, it is safe to flip tracking on, since the numbers already stored on each variant become authoritative the moment inventory_tracking is "variant". If any variant's stock was never counted, do not repair it. Flag it instead so a human can set a correct starting count first.

apply.py
def all_variants_have_stock(variants, affected_ids):
    by_id = {v["id"]: v for v in variants}
    return all(by_id.get(vid, {}).get("inventory_level") is not None for vid in affected_ids)

def set_variant_tracking(product_id):
    return bc("PUT", f"/v3/catalog/products/{product_id}", json={"inventory_tracking": "variant"})
apply.js
function allVariantsHaveStock(variants, affectedIds) {
  const byId = new Map(variants.map((v) => [v.id, v]));
  return affectedIds.every((vid) => (byId.get(vid) || {}).inventory_level != null);
}

async function setVariantTracking(productId) {
  return bc("PUT", `/v3/catalog/products/${productId}`, { inventory_tracking: "variant" });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs which products it would repair and which it would flag. Read the flagged list yourself, since those are the products where a variant has no starting stock count yet. Run the job on a schedule, for example nightly or after each catalog import.

Run it safe

Always start with DRY_RUN=true. Never flip inventory_tracking to "variant" on a product where any affected variant's inventory_level is null or missing, because that would show a false zero and block sales that should be allowed. Those go to the flag pile, not the repair pile, until a human sets the real count.

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 never mutates a stock count and never flips tracking on a product with a missing variant count.

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

fix_variant_inventory_tracking.py
"""Find and safely repair BigCommerce products whose variant inventory is not tracked.

inventory_tracking on the parent product is a tri-state setting: "none", "product",
or "variant". It is independent of whether the product actually has variants. A
product can have real size or color SKUs, each carrying its own inventory_level,
while inventory_tracking stays at "none" or "product". In that state BigCommerce's
checkout never reads or decrements per-SKU stock, so a variant can sell forever no
matter what number is displayed in the admin.

This scans every product with GET /v3/catalog/products?include=variants, classifies
each one with a pure function, and for products that need a fix, checks whether
every affected variant already has a non-null inventory_level. If so it is safe to
flip inventory_tracking to "variant" with one PUT. If any variant has no stock
count yet, the product is only flagged, never auto-repaired, since enabling
tracking on a missing count would show a false zero and block real sales. Guarded
by DRY_RUN. Safe to run again and again.
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body


def classify_variant_tracking(product):
    """Pure classification. No network calls.

    product: {"id": int, "inventory_tracking": "none" | "product" | "variant",
              "variants": [{"id": int, "sku": str, "inventory_level": int|None}]}

    Returns {"productId", "needsFix", "reason", "affectedVariantIds"}.

    A product with one or zero variants is left alone, since a single default
    variant is expected even for simple products. A product already tracking at
    "variant" is already correct. Otherwise, a product with real option-level SKUs
    and tracking at "none" or "product" needs a fix, and every variant id is
    returned as affected so the caller can check their inventory_level next.
    """
    variants = product.get("variants") or []
    if len(variants) <= 1:
        return {"productId": product["id"], "needsFix": False, "reason": None, "affectedVariantIds": []}

    if product.get("inventory_tracking") == "variant":
        return {"productId": product["id"], "needsFix": False, "reason": None, "affectedVariantIds": []}

    reason = (
        "tracking_disabled_entirely"
        if product.get("inventory_tracking") == "none"
        else "tracking_set_to_product_level_not_variant"
    )
    return {
        "productId": product["id"],
        "needsFix": True,
        "reason": reason,
        "affectedVariantIds": [v["id"] for v in variants],
    }


def all_variants_have_stock(variants, affected_ids):
    by_id = {v["id"]: v for v in variants}
    return all(by_id.get(vid, {}).get("inventory_level") is not None for vid in affected_ids)


def all_products():
    page = 1
    limit = 250
    while True:
        batch = bc("GET", f"/v3/catalog/products?include=variants&limit={limit}&page={page}")
        if not batch:
            return
        for product in batch:
            yield product
        if len(batch) < limit:
            return
        page += 1


def set_variant_tracking(product_id):
    return bc("PUT", f"/v3/catalog/products/{product_id}", json={"inventory_tracking": "variant"})


def run():
    repaired = 0
    flagged = 0
    for product in all_products():
        decision = classify_variant_tracking(product)
        if not decision["needsFix"]:
            continue

        variants = product.get("variants") or []
        if not all_variants_have_stock(variants, decision["affectedVariantIds"]):
            log.warning(
                "Product %s needs a fix (%s) but has a variant with no inventory_level. Flagging for review.",
                decision["productId"], decision["reason"],
            )
            flagged += 1
            continue

        log.info(
            "Product %s eligible (%s). %s",
            decision["productId"], decision["reason"],
            "would set inventory_tracking=variant" if DRY_RUN else "setting inventory_tracking=variant",
        )
        if not DRY_RUN:
            result = set_variant_tracking(decision["productId"])
            log.info("Product %s inventory_tracking is now %s", decision["productId"], result.get("inventory_tracking"))
        repaired += 1

    log.info(
        "Done. %d product(s) %s, %d product(s) flagged for review.",
        repaired, "to repair" if DRY_RUN else "repaired", flagged,
    )


if __name__ == "__main__":
    run()
fix-variant-inventory-tracking.js
/**
 * Find and safely repair BigCommerce products whose variant inventory is not tracked.
 *
 * inventory_tracking on the parent product is a tri-state setting: "none", "product",
 * or "variant". It is independent of whether the product actually has variants. A
 * product can have real size or color SKUs, each carrying its own inventory_level,
 * while inventory_tracking stays at "none" or "product". In that state BigCommerce's
 * checkout never reads or decrements per-SKU stock, so a variant can sell forever no
 * matter what number is displayed in the admin.
 *
 * This scans every product with GET /v3/catalog/products?include=variants, classifies
 * each one with a pure function, and for products that need a fix, checks whether
 * every affected variant already has a non-null inventory_level. If so it is safe to
 * flip inventory_tracking to "variant" with one PUT. If any variant has no stock
 * count yet, the product is only flagged, never auto-repaired, since enabling
 * tracking on a missing count would show a false zero and block real sales. Guarded
 * by DRY_RUN. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/variant-inventory-not-tracked/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store-hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure classification. No network calls.
 *
 * product: { id, inventory_tracking: "none" | "product" | "variant",
 *            variants: [{ id, sku, inventory_level }] }
 *
 * Returns { productId, needsFix, reason, affectedVariantIds }.
 *
 * A product with one or zero variants is left alone, since a single default
 * variant is expected even for simple products. A product already tracking at
 * "variant" is already correct. Otherwise, a product with real option-level SKUs
 * and tracking at "none" or "product" needs a fix, and every variant id is
 * returned as affected so the caller can check their inventory_level next.
 */
export function classifyVariantTracking(product) {
  const variants = product.variants || [];
  if (variants.length <= 1) {
    return { productId: product.id, needsFix: false, reason: null, affectedVariantIds: [] };
  }

  if (product.inventory_tracking === "variant") {
    return { productId: product.id, needsFix: false, reason: null, affectedVariantIds: [] };
  }

  const reason =
    product.inventory_tracking === "none"
      ? "tracking_disabled_entirely"
      : "tracking_set_to_product_level_not_variant";

  return {
    productId: product.id,
    needsFix: true,
    reason,
    affectedVariantIds: variants.map((v) => v.id),
  };
}

function allVariantsHaveStock(variants, affectedIds) {
  const byId = new Map(variants.map((v) => [v.id, v]));
  return affectedIds.every((vid) => (byId.get(vid) || {}).inventory_level != null);
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}

async function* allProducts() {
  let page = 1;
  const limit = 250;
  while (true) {
    const batch = await bc("GET", `/v3/catalog/products?include=variants&limit=${limit}&page=${page}`);
    if (!batch || !batch.length) return;
    for (const product of batch) yield product;
    if (batch.length < limit) return;
    page += 1;
  }
}

async function setVariantTracking(productId) {
  return bc("PUT", `/v3/catalog/products/${productId}`, { inventory_tracking: "variant" });
}

export async function run() {
  let repaired = 0;
  let flagged = 0;
  for await (const product of allProducts()) {
    const decision = classifyVariantTracking(product);
    if (!decision.needsFix) continue;

    const variants = product.variants || [];
    if (!allVariantsHaveStock(variants, decision.affectedVariantIds)) {
      console.warn(
        `Product ${decision.productId} needs a fix (${decision.reason}) but has a variant with no inventory_level. Flagging for review.`
      );
      flagged++;
      continue;
    }

    console.log(
      `Product ${decision.productId} eligible (${decision.reason}). ${DRY_RUN ? "would set inventory_tracking=variant" : "setting inventory_tracking=variant"}`
    );
    if (!DRY_RUN) {
      const result = await setVariantTracking(decision.productId);
      console.log(`Product ${decision.productId} inventory_tracking is now ${result && result.inventory_tracking}`);
    }
    repaired++;
  }
  console.log(`Done. ${repaired} product(s) ${DRY_RUN ? "to repair" : "repaired"}, ${flagged} product(s) flagged for review.`);
}

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

Add a test

The classification rule is the part most worth testing, because it decides whether checkout starts enforcing real stock on a SKU. Because we kept classify_variant_tracking pure, the test needs no network and no BigCommerce store. It just feeds in plain product and variant objects and checks the answer.

test_variant_tracking_classification.py
from fix_variant_inventory_tracking import classify_variant_tracking


def product(**over):
    base = {
        "id": 501,
        "inventory_tracking": "none",
        "variants": [
            {"id": 1, "sku": "SHIRT-S", "inventory_level": 5},
            {"id": 2, "sku": "SHIRT-M", "inventory_level": 3},
        ],
    }
    base.update(over)
    return base


def test_needs_fix_when_tracking_none_with_multiple_variants():
    result = classify_variant_tracking(product())
    assert result["needsFix"] is True
    assert result["reason"] == "tracking_disabled_entirely"
    assert result["affectedVariantIds"] == [1, 2]


def test_needs_fix_when_tracking_product_level():
    result = classify_variant_tracking(product(inventory_tracking="product"))
    assert result["needsFix"] is True
    assert result["reason"] == "tracking_set_to_product_level_not_variant"


def test_no_fix_when_already_tracking_variant():
    result = classify_variant_tracking(product(inventory_tracking="variant"))
    assert result == {"productId": 501, "needsFix": False, "reason": None, "affectedVariantIds": []}


def test_no_fix_when_single_default_variant():
    single = product(variants=[{"id": 1, "sku": "SIMPLE", "inventory_level": 10}])
    result = classify_variant_tracking(single)
    assert result["needsFix"] is False


def test_no_fix_when_no_variants_at_all():
    result = classify_variant_tracking(product(variants=[]))
    assert result["needsFix"] is False


def test_affected_variant_ids_include_every_variant():
    three = product(variants=[
        {"id": 1, "sku": "A", "inventory_level": 1},
        {"id": 2, "sku": "B", "inventory_level": 0},
        {"id": 3, "sku": "C", "inventory_level": None},
    ])
    result = classify_variant_tracking(three)
    assert result["needsFix"] is True
    assert result["affectedVariantIds"] == [1, 2, 3]
variant-tracking-classification.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyVariantTracking } from "./fix-variant-inventory-tracking.js";

const product = (over = {}) => ({
  id: 501,
  inventory_tracking: "none",
  variants: [
    { id: 1, sku: "SHIRT-S", inventory_level: 5 },
    { id: 2, sku: "SHIRT-M", inventory_level: 3 },
  ],
  ...over,
});

test("needs fix when tracking none with multiple variants", () => {
  const result = classifyVariantTracking(product());
  assert.equal(result.needsFix, true);
  assert.equal(result.reason, "tracking_disabled_entirely");
  assert.deepEqual(result.affectedVariantIds, [1, 2]);
});

test("needs fix when tracking product level", () => {
  const result = classifyVariantTracking(product({ inventory_tracking: "product" }));
  assert.equal(result.needsFix, true);
  assert.equal(result.reason, "tracking_set_to_product_level_not_variant");
});

test("no fix when already tracking variant", () => {
  const result = classifyVariantTracking(product({ inventory_tracking: "variant" }));
  assert.deepEqual(result, { productId: 501, needsFix: false, reason: null, affectedVariantIds: [] });
});

test("no fix when single default variant", () => {
  const single = product({ variants: [{ id: 1, sku: "SIMPLE", inventory_level: 10 }] });
  assert.equal(classifyVariantTracking(single).needsFix, false);
});

test("no fix when no variants at all", () => {
  assert.equal(classifyVariantTracking(product({ variants: [] })).needsFix, false);
});

test("affected variant ids include every variant", () => {
  const three = product({
    variants: [
      { id: 1, sku: "A", inventory_level: 1 },
      { id: 2, sku: "B", inventory_level: 0 },
      { id: 3, sku: "C", inventory_level: null },
    ],
  });
  const result = classifyVariantTracking(three);
  assert.equal(result.needsFix, true);
  assert.deepEqual(result.affectedVariantIds, [1, 2, 3]);
});

Case studies

Apparel import

The size run that oversold every week

An apparel store imported a few hundred SKUs by CSV, each with a size and color option and a starting inventory_level column filled in from the warehouse system. The import mapped inventory_level correctly onto every variant, but the import template never set inventory_tracking, so it stayed at "none" on every one of those products.

Popular sizes sold well past zero for weeks before someone noticed shipments could not keep up. The scan found dozens of multi-variant products stuck on "none", confirmed every variant already had the warehouse counts loaded, and flipped tracking to "variant" in one pass. Overselling on those SKUs stopped the same day.

New product launch

The new colorway with no stock count yet

A merchant added a new color option to an existing shoe product through the control panel, creating three new SKUs, but had not yet received the first shipment or set a starting inventory_level for them. An automated sweep, if it had blindly flipped tracking to "variant" on that product, would have shown all three new SKUs as zero in stock and blocked pre-orders the marketing team was counting on.

Because the script checks every affected variant's inventory_level before writing anything, it flagged that product for review instead of touching it. The merchant filled in the pre-order count a day later, tracking was turned on then, and nothing was ever falsely blocked.

What good looks like

After this runs on a schedule, a product with real variants cannot quietly sit with tracking off. Every product where the numbers are already trustworthy gets checkout enforcement turned on with one safe field, and every product with an unset count gets a flag instead of a false zero. Oversold SKUs stop being a surprise, and nothing ever gets blocked by a number nobody entered yet.

FAQ

Why does a BigCommerce variant sell past its displayed stock?

Because inventory tracking on the parent product is set to none or product instead of variant. BigCommerce's checkout and order pipeline only reads and decrements each SKU's own inventory_level when inventory_tracking is set to variant. If it is left at none or set to product level, the number shown on a variant is cosmetic and checkout never blocks or decrements it, so the SKU can sell indefinitely.

Is it safe to just flip inventory_tracking to variant on every affected product?

Only when every affected variant already has a non-null inventory_level. If a variant has never had its stock counted, its inventory_level may be null or zero by default, and flipping tracking on immediately would show that SKU as zero in stock and block sales that should be allowed. Those products should be flagged for a human to set a correct starting count first, not auto-repaired.

How do I detect this without changing anything?

Call GET /v3/catalog/products?include=variants and look at each product's inventory_tracking alongside its variants array. A product with more than one variant and inventory_tracking of none or product is affected. This is a read-only check, so you can run it safely before deciding what, if anything, to repair.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: Inventory Tracking. support.bigcommerce.com/s/article/Inventory-Tracking
  2. BigCommerce Support: BigCommerce Inventory Functionality. support.bigcommerce.com/s/article/BigCommerce-Inventory-Functionality
  3. BigCommerce Community: What is the best API endpoint to get inventory levels at the variant level? support.bigcommerce.com inventory-levels-at-the-variant-level

On the solution:

  1. BigCommerce Developer Center: Product Variants (REST Catalog). developer.bigcommerce.com/docs/rest-catalog/product-variants
  2. BigCommerce Developer Center: Inventory (REST Management). developer.bigcommerce.com/docs/rest-management/inventory
  3. BigCommerce API Reference: Catalog, Product Variants. docs.bigcommerce.com/developer/api-reference/rest/admin/catalog/product-variants

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, webhooks, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch some phantom stock?

If this stopped a SKU from overselling, 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