Diagnostic Inventory and catalog
Out of stock but still purchasable
The admin shows zero units left. The warehouse agrees, there is nothing on the shelf. But the storefront still has an Add to Cart button, and the API still accepts the order. Here is why BigCommerce sometimes never looks at a SKU's stock at all, and a small script that finds every product where checkout should be blocked but is not, and flags it for a human instead of guessing.
BigCommerce only blocks checkout for a SKU when three fields agree: inventory_tracking is scoped correctly ("product" for a simple product or "variant" for one with SKU-level options), the matching inventory_level is actually at or below zero, and availability is not forced to "available". The most common break is inventory_tracking left at "none", or set at the product level while stock is really managed per variant. In that state BigCommerce never evaluates stock at all, so the storefront and API happily accept orders regardless of inventory_level. The same gap shows up after an import or ERP sync writes zero to the product but not its variants, or after a dead inventory webhook leaves inventory_level stale. Run a small Python or Node.js script that scans products and variants, flags every "phantom in-stock" record, and leaves the actual repair to a confirmed, one-record-at-a-time write. Full code, tests, and a dry run guard are below.
The problem in plain words
A merchant expects one simple rule: if stock is zero, the store stops selling it. BigCommerce actually checks three separate fields before it agrees to block a sale, and all three have to line up. inventory_tracking has to be scoped to where the stock number actually lives, that number has to be at or below zero, and availability has to not be pinned to "available" regardless of stock.
Most of the time these three agree and nobody notices the wiring. The trouble starts when only one of them gets updated. A CSV import sets a product's own inventory_level to 0 but never touches its child variants. A webhook that used to push warehouse counts into BigCommerce goes quiet after failing enough times. A product that was correctly configured for simple stock tracking gets variants added later, and tracking never moves from "product" to "variant". In every case, the admin can show a zero, the warehouse can agree there is nothing left, and the storefront still lets someone buy it.
Why it happens
None of the three fields update each other automatically. A few common ways stores end up with a phantom in-stock record:
inventory_trackingis left at"none"from a default product setup, or set to"product"while the merchant actually manages stock per variant, so BigCommerce never looks atinventory_levelat all.- An import or ERP sync writes a fresh
inventory_levelof 0 onto the parent product but leaves the child variants' owninventory_leveluntouched, or the reverse, so the two disagree about what is actually left. - A webhook subscribed to an
inventory.*scope stops delivering after BigCommerce automatically deactivates it following repeated non-2xx responses from the receiving endpoint, soinventory_levelquietly goes stale whileavailabilitystill reads"available". availabilitywas manually set to"available"for a launch or a promotion and never reset, which overrides whatever the stock count says.
This is a common source of oversells. The merchant sees the correct number in one place, assumes the rest of the system agrees, and only finds out from a customer complaint or a fulfillment team that cannot ship what was just sold. See the citations at the end for the exact support articles and API docs.
Forcing inventory_tracking, inventory_level, and availability to agree on live catalog data can hide products a merchant intentionally sells without tracking, such as services or made-to-order items. So the safe pattern here is not "disable anything that looks out of stock." It is "flag every record where the three fields disagree with what the merchant's own data implies, and only write a correction after a human confirms it per product."
The fix, as a flow
We do not touch live availability by default. We add a job that lists products and their variants, reads back the four fields the decision needs, and marks every "phantom in-stock" record for review. A guarded, DRY_RUN-respecting write is available for the confirmed cases, and it always re-reads the record afterward to prove the change actually stuck.
Build it step by step
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.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true" # start safe, change to false to write
// 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
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.
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
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;
}
Scan every product with its variants
Call GET /v3/catalog/products?include=variants&limit=250 and page through the whole catalog using meta.pagination. Each record carries inventory_tracking, inventory_level, inventory_warning_level, and availability, and for SKU-tracked items the child variants add their own inventory_level and purchasing_disabled. This step never writes anything, it just reads.
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
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;
}
}
Decide, with one pure function
Keep the decision in its own function that takes only the four already-fetched scalar fields and returns true or false. It returns true, meaning flag as "out of stock but still purchasable," only when inventory_tracking is "product" or "variant", inventory_level is at or below zero, availability is "available", and purchasing_disabled is false. Anything untracked, already disabled, or genuinely still in stock returns false. Because it takes only scalars and does no I/O, it is trivial to test against a full matrix of tracking modes, levels, availability values, and disabled flags.
TRACKED_MODES = {"product", "variant"}
def is_stale_in_stock(inventory_tracking, inventory_level, availability, purchasing_disabled):
if inventory_tracking not in TRACKED_MODES:
return False
if inventory_level > 0:
return False
if availability != "available":
return False
return purchasing_disabled is False
const TRACKED_MODES = new Set(["product", "variant"]);
export function isStaleInStock(inventoryTracking, inventoryLevel, availability, purchasingDisabled) {
if (!TRACKED_MODES.has(inventoryTracking)) return false;
if (inventoryLevel > 0) return false;
if (availability !== "available") return false;
return purchasingDisabled === false;
}
Guard the write: only correct a product after explicit confirmation
This is a detect-and-flag case, not a safe blanket auto-fix, because forcing inventory_tracking or availability on live catalog data can hide products a merchant intentionally sells without tracking, like services or made-to-order items. So the corrective write only runs on a product id list a human has explicitly confirmed, and it always re-reads the record after writing to prove inventory_level and availability actually persisted.
CORRECTION_PAYLOAD = {"inventory_tracking": "product", "inventory_level": 0, "availability": "disabled"}
def disable_confirmed_product(product_id):
"""Only call this for a product_id a human has explicitly confirmed."""
bc("PUT", f"/v3/catalog/products/{product_id}", json=CORRECTION_PAYLOAD)
confirmed = bc("GET", f"/v3/catalog/products/{product_id}")
ok = (confirmed.get("inventory_level") == 0 and confirmed.get("availability") == "disabled")
if not ok:
raise RuntimeError(f"Product {product_id} did not persist the correction")
return confirmed
const CORRECTION_PAYLOAD = { inventory_tracking: "product", inventory_level: 0, availability: "disabled" };
async function disableConfirmedProduct(productId) {
// Only call this for a productId a human has explicitly confirmed.
await bc("PUT", `/v3/catalog/products/${productId}`, CORRECTION_PAYLOAD);
const confirmed = await bc("GET", `/v3/catalog/products/${productId}`);
const ok = confirmed.inventory_level === 0 && confirmed.availability === "disabled";
if (!ok) throw new Error(`Product ${productId} did not persist the correction`);
return confirmed;
}
Wire it together with a dry run guard
The loop scans every product and variant, runs is_stale_in_stock, and logs every flagged record. On the first few runs, leave DRY_RUN on so the script only reports what it found. It never writes on its own, even with DRY_RUN=false, unless a record's id is in the confirmed list you pass in, which keeps the correction opt-in per product. Run the scan on a schedule, for example nightly or after each catalog import or ERP sync.
Always start with DRY_RUN=true. Never force inventory_tracking, inventory_level, or availability on a product that has not been explicitly confirmed, because some products are meant to sell without stock tracking. Also check GET /v3/hooks for a deactivated inventory webhook, since a dead webhook is the usual reason inventory_level went stale in the first place, and recreate it so future stock changes stop drifting.
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 never writes to a product unless its id is in the explicitly confirmed set, so running it again and again only ever adds more visibility, never surprise mutations.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find BigCommerce products and variants that are out of stock but still purchasable.
BigCommerce only blocks checkout for a SKU when three fields agree: inventory_tracking
is scoped correctly ("product" for simple products or "variant" for SKU-level options),
the matching inventory_level is at or below zero, and availability is not forced to
"available". A common break is inventory_tracking left at "none", or scoped at the
product level while stock is really managed per variant. In that state BigCommerce
never evaluates stock at all, so the storefront and API accept orders no matter what
inventory_level says. The same gap shows up after an import writes zero to a product
but not its variants, or after a dead inventory webhook leaves inventory_level stale.
This scans every product with GET /v3/catalog/products?include=variants, classifies
each product and each of its variants with a pure function, and logs every "phantom
in-stock" record. This is a detect-and-flag tool: it never mutates live availability
on its own. A correction only runs for a product id you pass in CONFIRMED_PRODUCT_IDS,
meaning a human has verified the product should truly be disabled, and even then it is
guarded by DRY_RUN and re-reads the record afterward to confirm the write persisted.
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("find_stale_in_stock")
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"
# Product ids a human has explicitly confirmed should be corrected. Left empty by
# default so the script only ever flags until you deliberately opt a product in.
CONFIRMED_PRODUCT_IDS = {
int(x) for x in os.environ.get("CONFIRMED_PRODUCT_IDS", "").split(",") if x.strip()
}
TRACKED_MODES = {"product", "variant"}
CORRECTION_PAYLOAD = {"inventory_tracking": "product", "inventory_level": 0, "availability": "disabled"}
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 is_stale_in_stock(inventory_tracking, inventory_level, availability, purchasing_disabled):
"""Pure decision. No network calls, no side effects.
Returns True (flag as "out of stock but still purchasable") only when
inventory_tracking is in ("product", "variant"), inventory_level is at or
below zero, availability is "available", and purchasing_disabled is False.
Returns False for untracked SKUs, correctly disabled SKUs, or SKUs genuinely
still in stock.
"""
if inventory_tracking not in TRACKED_MODES:
return False
if inventory_level > 0:
return False
if availability != "available":
return False
return purchasing_disabled is False
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 flagged_records(product):
"""Yield (kind, id, sku) tuples for every stale in-stock record on a product."""
if is_stale_in_stock(
product.get("inventory_tracking"),
product.get("inventory_level", 0),
product.get("availability"),
product.get("purchasing_disabled", False),
):
yield ("product", product["id"], product.get("sku", ""))
for variant in product.get("variants") or []:
if is_stale_in_stock(
product.get("inventory_tracking"),
variant.get("inventory_level", 0),
product.get("availability"),
variant.get("purchasing_disabled", False),
):
yield ("variant", variant["id"], variant.get("sku", ""))
def disable_confirmed_product(product_id):
"""Only call this for a product_id present in CONFIRMED_PRODUCT_IDS."""
bc("PUT", f"/v3/catalog/products/{product_id}", json=CORRECTION_PAYLOAD)
confirmed = bc("GET", f"/v3/catalog/products/{product_id}")
ok = confirmed.get("inventory_level") == 0 and confirmed.get("availability") == "disabled"
if not ok:
raise RuntimeError(f"Product {product_id} did not persist the correction")
return confirmed
def run():
flagged = 0
corrected = 0
for product in all_products():
for kind, record_id, sku in flagged_records(product):
flagged += 1
log.warning(
"%s %s (sku=%s) is out of stock but still purchasable.",
kind, record_id, sku,
)
if kind == "product" and record_id in CONFIRMED_PRODUCT_IDS:
log.info(
"Product %s is confirmed. %s",
record_id, "would disable" if DRY_RUN else "disabling",
)
if not DRY_RUN:
disable_confirmed_product(record_id)
corrected += 1
log.info(
"Done. %d record(s) flagged, %d confirmed product(s) %s.",
flagged, corrected, "to correct" if DRY_RUN else "corrected",
)
if __name__ == "__main__":
run()
/**
* Find BigCommerce products and variants that are out of stock but still purchasable.
*
* BigCommerce only blocks checkout for a SKU when three fields agree: inventory_tracking
* is scoped correctly ("product" for simple products or "variant" for SKU-level options),
* the matching inventory_level is at or below zero, and availability is not forced to
* "available". A common break is inventory_tracking left at "none", or scoped at the
* product level while stock is really managed per variant. In that state BigCommerce
* never evaluates stock at all, so the storefront and API accept orders no matter what
* inventory_level says. The same gap shows up after an import writes zero to a product
* but not its variants, or after a dead inventory webhook leaves inventory_level stale.
*
* This scans every product with GET /v3/catalog/products?include=variants, classifies
* each product and each of its variants with a pure function, and logs every "phantom
* in-stock" record. This is a detect-and-flag tool: it never mutates live availability
* on its own. A correction only runs for a product id you pass in CONFIRMED_PRODUCT_IDS,
* meaning a human has verified the product should truly be disabled, and even then it is
* guarded by DRY_RUN and re-reads the record afterward to confirm the write persisted.
* Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/out-of-stock-but-still-purchasable/
*/
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";
// Product ids a human has explicitly confirmed should be corrected. Left empty by
// default so the script only ever flags until you deliberately opt a product in.
const CONFIRMED_PRODUCT_IDS = new Set(
(process.env.CONFIRMED_PRODUCT_IDS || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.map(Number)
);
const TRACKED_MODES = new Set(["product", "variant"]);
const CORRECTION_PAYLOAD = { inventory_tracking: "product", inventory_level: 0, availability: "disabled" };
/**
* Pure decision. No network calls, no side effects.
*
* Returns true (flag as "out of stock but still purchasable") only when
* inventoryTracking is "product" or "variant", inventoryLevel is at or below
* zero, availability is "available", and purchasingDisabled is false. Returns
* false for untracked SKUs, correctly disabled SKUs, or SKUs genuinely still
* in stock.
*/
export function isStaleInStock(inventoryTracking, inventoryLevel, availability, purchasingDisabled) {
if (!TRACKED_MODES.has(inventoryTracking)) return false;
if (inventoryLevel > 0) return false;
if (availability !== "available") return false;
return purchasingDisabled === false;
}
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;
}
}
function* flaggedRecords(product) {
if (
isStaleInStock(
product.inventory_tracking,
product.inventory_level ?? 0,
product.availability,
product.purchasing_disabled ?? false
)
) {
yield { kind: "product", id: product.id, sku: product.sku || "" };
}
for (const variant of product.variants || []) {
if (
isStaleInStock(
product.inventory_tracking,
variant.inventory_level ?? 0,
product.availability,
variant.purchasing_disabled ?? false
)
) {
yield { kind: "variant", id: variant.id, sku: variant.sku || "" };
}
}
}
async function disableConfirmedProduct(productId) {
// Only call this for a productId present in CONFIRMED_PRODUCT_IDS.
await bc("PUT", `/v3/catalog/products/${productId}`, CORRECTION_PAYLOAD);
const confirmed = await bc("GET", `/v3/catalog/products/${productId}`);
const ok = confirmed.inventory_level === 0 && confirmed.availability === "disabled";
if (!ok) throw new Error(`Product ${productId} did not persist the correction`);
return confirmed;
}
export async function run() {
let flagged = 0;
let corrected = 0;
for await (const product of allProducts()) {
for (const record of flaggedRecords(product)) {
flagged++;
console.warn(`${record.kind} ${record.id} (sku=${record.sku}) is out of stock but still purchasable.`);
if (record.kind === "product" && CONFIRMED_PRODUCT_IDS.has(record.id)) {
console.log(`Product ${record.id} is confirmed. ${DRY_RUN ? "would disable" : "disabling"}`);
if (!DRY_RUN) await disableConfirmedProduct(record.id);
corrected++;
}
}
}
console.log(`Done. ${flagged} record(s) flagged, ${corrected} confirmed product(s) ${DRY_RUN ? "to correct" : "corrected"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which products are told they are still selling past zero stock. Because is_stale_in_stock is pure, the test needs no network and no BigCommerce store. It just feeds in the four scalar fields and checks the answer against every combination that matters.
from find_stale_in_stock import is_stale_in_stock
def test_flags_when_tracked_product_level_zero_and_available():
assert is_stale_in_stock("product", 0, "available", False) is True
def test_flags_when_tracked_variant_level_negative_and_available():
assert is_stale_in_stock("variant", -1, "available", False) is True
def test_no_flag_when_tracking_is_none():
assert is_stale_in_stock("none", 0, "available", False) is False
def test_no_flag_when_still_in_stock():
assert is_stale_in_stock("product", 5, "available", False) is False
def test_no_flag_when_already_disabled():
assert is_stale_in_stock("product", 0, "disabled", False) is False
def test_no_flag_when_purchasing_already_disabled():
assert is_stale_in_stock("variant", 0, "available", True) is False
def test_no_flag_when_preorder_availability():
assert is_stale_in_stock("product", 0, "preorder", False) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStaleInStock } from "./find-stale-in-stock.js";
test("flags when tracked at product level, zero, and available", () => {
assert.equal(isStaleInStock("product", 0, "available", false), true);
});
test("flags when tracked at variant level, negative, and available", () => {
assert.equal(isStaleInStock("variant", -1, "available", false), true);
});
test("no flag when tracking is none", () => {
assert.equal(isStaleInStock("none", 0, "available", false), false);
});
test("no flag when still in stock", () => {
assert.equal(isStaleInStock("product", 5, "available", false), false);
});
test("no flag when already disabled", () => {
assert.equal(isStaleInStock("product", 0, "disabled", false), false);
});
test("no flag when purchasing already disabled", () => {
assert.equal(isStaleInStock("variant", 0, "available", true), false);
});
test("no flag when preorder availability", () => {
assert.equal(isStaleInStock("product", 0, "preorder", false), false);
});
Case studies
The nightly feed that zeroed the parent, not the variants
A furniture store synced stock from an ERP every night. A schema change meant the feed started writing inventory_level onto the parent product record when a run went out of stock, but the actual SKU-level counts lived on the variants, and inventory_tracking was still set to "variant". Checkout kept reading the variant numbers, which the broken feed never touched, so the storefront sold a run that had been sitting at zero in the ERP for two weeks.
The scan flagged every variant where inventory_level was at or below zero with tracking and availability still saying available. That list went straight to the ops team, who fixed the feed mapping and confirmed the specific SKUs for the guarded correction. Nothing was disabled automatically, so no in-stock product was ever touched by mistake.
The webhook that had been silently deactivated for a month
A store used an inventory.* webhook to keep BigCommerce in sync with a warehouse system. A deploy on the receiving endpoint introduced a bug that made it return 500 for a stretch of requests, and BigCommerce automatically deactivated the webhook after enough failures. Nobody noticed, because everything else on the site kept working, and stock numbers just quietly stopped updating.
Running the scan turned up dozens of SKUs at zero or negative stock, still marked available, all showing the same stale pattern. Checking GET /v3/hooks confirmed the webhook's is_active was false. The team recreated it, backfilled the real counts, and only then confirmed the affected products for the one-at-a-time correction.
After this runs on a schedule, a SKU cannot quietly sell past zero without someone finding out the same day. Every stale record surfaces with its product or variant id and SKU, nothing gets disabled without a human confirming it first, and a dead inventory webhook gets caught and reconnected instead of staying silently broken for weeks.
FAQ
Why does BigCommerce let customers buy a product that shows zero stock?
BigCommerce only blocks checkout for a SKU when three fields agree: inventory_tracking is scoped correctly at product or variant level, inventory_level is at or below zero, and availability is not forced to available. If inventory_tracking is left at none, or set at the product level while stock is really managed per variant, BigCommerce never evaluates stock at all, so the storefront and API accept orders no matter what inventory_level says.
Is it safe to automatically disable every product this check flags?
No. This is a detect and flag case, not a blanket auto fix, because some merchants intentionally sell services or made-to-order items without tracking. The safe pattern writes a private marker for merchant review and only forces inventory_tracking, inventory_level, and availability changes per product after an explicit confirmation, guarded by DRY_RUN and applied one record at a time.
What usually causes inventory_level to go stale in the first place?
The most common cause is a dead inventory webhook. BigCommerce automatically deactivates a webhook subscribed to an inventory.* scope after enough non-2xx responses from your endpoint, so future stock changes stop propagating and inventory_level drifts away from the real warehouse count while availability still reads available. Checking GET /v3/hooks for is_active false is part of the detection pass for this reason.
Related field notes
Citations
On the problem:
- BigCommerce Support: Why are customers able to order out of stock/hidden products? support.bigcommerce.com why-are-customers-able-to-order-out-of-stockhidden-products
- BigCommerce Support: Allowing out-of-stock purchases while tracking inventory. support.bigcommerce.com allowing-out-of-stock-purchases-while-tracking-inventory
- BigCommerce Help Center: Inventory Tracking. support.bigcommerce.com/s/article/Inventory-Tracking
On the solution:
- BigCommerce API Reference: Update Product. developer.bigcommerce.com/docs/rest-management/catalog/products
- BigCommerce API Reference: Product Variants. developer.bigcommerce.com/docs/rest-catalog/product-variants
- BigCommerce Developer Center: Inventory. developer.bigcommerce.com/docs/rest-management/settings/inventory
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.
Did this catch a phantom in-stock SKU?
If this stopped a product from overselling past zero, 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