Reconciler Inventory and catalog
Available drifts from real on-hand
A cycle count comes back from the warehouse and a SKU that BigCommerce shows as 12 available is really 7 on the shelf, or the other way around. Nobody changed anything on purpose. The storefront number is only ever as good as the last write to it, and orders, cancellations, refunds, and bulk imports all write to it independently. Here is why that number quietly drifts and a small script that pulls a real count, compares it to what BigCommerce has, and reconciles only the SKUs that actually need it.
BigCommerce's "available" number is just the inventory_level field on the product or variant record. It only reflects reality if every writer that touches it stayed in sync: order placement decrementing it, a cancellation or refund adding it back, a bulk import writing a fresh count, or a human editing it by hand. A failed webhook, a mid-flight Inventory API import, or a race between two of those writers leaves the number wrong with no alarm. Run a small Python or Node.js script that pulls your counted on-hand quantities, reads every tracked variant's inventory_level from GET /v3/catalog/products?include=variants, cross-checks recent order status for cancellations that were never restocked, and plans an absolute adjustment for only the SKUs that actually drifted. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce does not track inventory the way a warehouse does. There is no ledger of every unit in and every unit out that it reconciles against a physical shelf. There is one field, inventory_level, sitting on the product or variant record, and the storefront shows whatever that field currently says. It is a snapshot, not a count.
That snapshot only stays correct if every process that is allowed to write to it behaves. Orders decrement it when a sale goes through. Cancellations, declined payments, and refunds are supposed to add it back. A bulk import through the Inventory API can overwrite it with a fresh number. A staff member can edit it directly in the admin. Each of those is a separate writer, running at a separate time, and none of them checks what the others just did. The moment one of them fails, retries, or overlaps with another, the number on the record and the units on the shelf stop agreeing, and nothing in BigCommerce tells you that happened.
Why it happens
inventory_level only moves when something tells it to, and BigCommerce trusts whatever that last write said. A few common ways it drifts from a true recount:
- An order is cancelled, declined, or refunded, but whatever process was supposed to restore its stock (a webhook handler, an app, a manual step) never ran, silently retried and failed, or was never wired up for that status at all.
- Orders decrement stock at order-placed or order-completed time depending on the store's or channel's inventory settings, and a store that changes that setting mid-stream can end up double-counting or under-counting a batch of orders around the change.
- A bulk adjustment through the Inventory API runs at the same time as a Catalog API write or an Orders API write touching the same SKUs. BigCommerce's own docs warn the Inventory API is "not channel aware" and that concurrent writes from multiple APIs can produce unpredictable, incorrect stock calculations.
- A staff member edits a variant's stock by hand in the admin to patch one thing, and that edit is never reconciled against the warehouse system, so it quietly becomes the new wrong number.
None of these show up as an error. The store keeps selling against whatever inventory_level says, right up until a physical count exposes the gap. See the citations at the end for the exact support articles and API docs.
You cannot compute your way out of drift by tracking every delta, because you do not actually know which deltas were applied correctly and which were lost. The reliable fix is to trust a real count over the running number. Pull the true on-hand quantity from a WMS, cycle count, or POS/ERP feed, compare it against inventory_level for tracked SKUs only, and where they differ, write the counted value as an absolute override rather than trying to guess and apply a delta on top of a number you already do not trust.
The fix, as a flow
We do not try to reconstruct history. We add a job that reads the counted quantities you already trust, reads what BigCommerce currently has for every stock-managed SKU, and for any SKU where the two disagree, plans one absolute adjustment that sets inventory_level to the counted value. A SKU that only shows up in BigCommerce with no counted quantity is skipped, since guessing is worse than leaving it alone.
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 BIGCOMMERCE_LOCATION_ID="1"
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 BIGCOMMERCE_LOCATION_ID="1"
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;
}
Read every tracked variant, and the recent order context
Page through GET /v3/catalog/products?include=variants&limit=250 and keep only variants whose parent's inventory_tracking is "variant" (or the product-level equivalent), since those are the only SKUs BigCommerce actually enforces stock on. Separately, pull recent order activity with GET /v2/orders?min_date_modified=... and each order's line items with GET /v2/orders/{id}/products, so you can tell whether a Cancelled(5), Declined(6), Refunded(4), or Partially Refunded(14) order ever had its stock restored.
def all_variants():
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:
tracking = product.get("inventory_tracking")
for v in product.get("variants") or []:
yield {
"sku": v.get("sku"),
"inventoryLevel": v.get("inventory_level", 0),
"inventoryTracking": tracking,
"locationId": DEFAULT_LOCATION_ID,
}
if len(batch) < limit:
return
page += 1
async function* allVariants() {
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) {
const tracking = product.inventory_tracking;
for (const v of product.variants || []) {
yield {
sku: v.sku,
inventoryLevel: v.inventory_level ?? 0,
inventoryTracking: tracking,
locationId: DEFAULT_LOCATION_ID,
};
}
}
if (batch.length < limit) return;
page += 1;
}
}
Decide, with one pure function
Keep the comparison in its own function that takes the tracked catalog variants, the counted quantities, and the recent order flags, and returns the list of adjustments to make. Only tracked variants are eligible. A SKU missing from the counted map is skipped outright, since there is no source of truth to act on. When a SKU differs, the reason is tagged "cancelled_not_restocked" when a Cancelled, Declined, Refunded, or Partially Refunded order for that SKU shows restocked: false, otherwise "recount_variance".
RESTOCK_STATUS_IDS = {4, 5, 6, 14} # Refunded, Cancelled, Declined, Partially Refunded
def plan_inventory_reconciliation(catalog_variants, counted_on_hand, recent_order_flags):
plan = []
for variant in catalog_variants:
if variant.get("inventoryTracking") == "none":
continue
sku = variant["sku"]
if sku not in counted_on_hand:
continue
to_qty = counted_on_hand[sku]
from_qty = variant["inventoryLevel"]
if to_qty == from_qty:
continue
reason = "recount_variance"
for flag in recent_order_flags.get(sku, []):
if flag.get("statusId") in RESTOCK_STATUS_IDS and flag.get("restocked") is False:
reason = "cancelled_not_restocked"
break
plan.append({
"sku": sku,
"locationId": variant.get("locationId", DEFAULT_LOCATION_ID),
"fromQty": from_qty,
"toQty": to_qty,
"reason": reason,
})
return plan
const RESTOCK_STATUS_IDS = new Set([4, 5, 6, 14]); // Refunded, Cancelled, Declined, Partially Refunded
export function planInventoryReconciliation(catalogVariants, countedOnHand, recentOrderFlags) {
const plan = [];
for (const variant of catalogVariants) {
if (variant.inventoryTracking === "none") continue;
const sku = variant.sku;
if (!countedOnHand.has(sku)) continue;
const toQty = countedOnHand.get(sku);
const fromQty = variant.inventoryLevel;
if (toQty === fromQty) continue;
let reason = "recount_variance";
for (const flag of recentOrderFlags.get(sku) || []) {
if (RESTOCK_STATUS_IDS.has(flag.statusId) && flag.restocked === false) {
reason = "cancelled_not_restocked";
break;
}
}
plan.push({
sku,
locationId: variant.locationId ?? DEFAULT_LOCATION_ID,
fromQty,
toQty,
reason,
});
}
return plan;
}
Write the counted value as one absolute override
Send the planned adjustments to PUT /v3/inventory/adjustments/absolute with a batch of {location_id, sku, quantity} entries, up to 2,000 per request. This sets inventory_level to the counted value directly, rather than computing and applying a delta on top of a number you already know is wrong. Never run this alongside a concurrent Catalog API or Orders API write on the same SKUs, since BigCommerce explicitly warns that concurrent inventory recalculation from multiple APIs can corrupt the result.
def submit_adjustments(plan):
items = [
{"location_id": row["locationId"], "sku": row["sku"], "quantity": row["toQty"]}
for row in plan
]
return bc(
"PUT",
"/v3/inventory/adjustments/absolute",
json={"reason": "reconciliation", "items": items},
)
async function submitAdjustments(plan) {
const items = plan.map((row) => ({ location_id: row.locationId, sku: row.sku, quantity: row.toQty }));
return bc("PUT", "/v3/inventory/adjustments/absolute", { reason: "reconciliation", items });
}
Wire it together with a dry run guard and a confirmation re-read
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs the planned {sku, from, to, location_id} adjustments. When you switch it off, it submits the batch and then re-reads the variants to confirm inventory_level now matches the counted source, warning on anything that did not stick.
Always start with DRY_RUN=true. Never run this job alongside a concurrent Catalog API write (PUT /v3/catalog/products/{id}/variants) or an Orders API write on the same SKUs. BigCommerce's own documentation warns that the Inventory API is not channel aware and that concurrent inventory recalculation from multiple APIs can produce unpredictable, incorrect results. Schedule reconciliation outside your import and heavy order-processing windows.
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 only writes an absolute adjustment for a SKU that has both a counted quantity and a real difference from what BigCommerce currently reports.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely repair BigCommerce inventory that has drifted from real on-hand counts.
BigCommerce's storefront "available" number is whatever inventory_level currently
says on the product or variant record, and that number is only as good as the last
write to it. Orders decrement stock, cancellations and refunds are supposed to add
it back, and a failed webhook, a mid-flight bulk import through the Inventory API,
or a manual admin edit can each leave inventory_level out of step with what a
warehouse recount shows. BigCommerce's own docs warn that the Inventory API is "not
channel aware" and that running Inventory API bulk adjustments in parallel with
Catalog or Orders API writes can produce unpredictable, incorrect stock
calculations, which is exactly the race that produces silent drift.
This pulls a counted source of truth (a WMS export, cycle-count CSV, or POS/ERP
feed), reads every tracked variant's sku and inventory_level from
GET /v3/catalog/products?include=variants, cross-checks recent order activity for
cancelled, declined, or refunded orders that were never restocked, and plans a set
of absolute inventory adjustments with a pure function. Under DRY_RUN it only logs
the plan. When DRY_RUN is false it submits the batch to
PUT /v3/inventory/adjustments/absolute and re-reads the variants to confirm the
counted value stuck. Never run this alongside a concurrent Catalog or Orders API
write on the same SKUs. 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("reconcile_inventory")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DEFAULT_LOCATION_ID = int(os.environ.get("BIGCOMMERCE_LOCATION_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Order statuses (V2) that should have restored stock. If the webhook or app that
# normally restocks a cancelled/declined/refunded order never ran, the SKU is left
# holding a lower inventory_level than reality, and that context gets attached to
# the adjustment reason so a human can see why the drift showed up.
RESTOCK_STATUS_IDS = {4, 5, 6, 14} # Refunded, Cancelled, Declined, Partially Refunded
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 plan_inventory_reconciliation(catalog_variants, counted_on_hand, recent_order_flags):
"""Pure data transform. No network calls.
catalog_variants: [{"sku": str, "inventoryLevel": int, "inventoryTracking":
"none"|"product"|"variant", "locationId": int}]
counted_on_hand: {sku: int} true counted quantity from the WMS/cycle count/ERP feed.
recent_order_flags: {sku: [{"statusId": int, "restocked": bool}]}
Returns a list of {"sku", "locationId", "fromQty", "toQty", "reason"} adjustment
records, one per SKU whose counted quantity differs from inventory_level.
Only variants with inventory_tracking != "none" are eligible, since an
untracked variant has no inventory_level BigCommerce actually enforces. A SKU
missing from counted_on_hand is skipped outright: with no source of truth we do
not guess a value. When present and different, the record is tagged
"cancelled_not_restocked" if recent_order_flags shows a Cancelled(5),
Declined(6), Refunded(4), or Partially Refunded(14) order for that sku with
restocked=False, otherwise "recount_variance".
"""
plan = []
for variant in catalog_variants:
if variant.get("inventoryTracking") == "none":
continue
sku = variant["sku"]
if sku not in counted_on_hand:
continue
to_qty = counted_on_hand[sku]
from_qty = variant["inventoryLevel"]
if to_qty == from_qty:
continue
reason = "recount_variance"
for flag in recent_order_flags.get(sku, []):
if flag.get("statusId") in RESTOCK_STATUS_IDS and flag.get("restocked") is False:
reason = "cancelled_not_restocked"
break
plan.append({
"sku": sku,
"locationId": variant.get("locationId", DEFAULT_LOCATION_ID),
"fromQty": from_qty,
"toQty": to_qty,
"reason": reason,
})
return plan
def all_variants():
"""Read-only. Pages every product with its variants and yields tracked variants
flattened to {"sku", "inventoryLevel", "inventoryTracking", "locationId"}.
"""
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:
tracking = product.get("inventory_tracking")
for v in product.get("variants") or []:
yield {
"sku": v.get("sku"),
"inventoryLevel": v.get("inventory_level", 0),
"inventoryTracking": tracking,
"locationId": DEFAULT_LOCATION_ID,
}
if len(batch) < limit:
return
page += 1
def submit_adjustments(plan):
"""Write path. Sets inventory_level to the counted value in one atomic override
per SKU using the V3 absolute adjustments endpoint. Up to 2000 items per batch.
"""
items = [
{"location_id": row["locationId"], "sku": row["sku"], "quantity": row["toQty"]}
for row in plan
]
return bc(
"PUT",
"/v3/inventory/adjustments/absolute",
json={"reason": "reconciliation", "items": items},
)
def run(counted_on_hand, recent_order_flags):
variants = list(all_variants())
plan = plan_inventory_reconciliation(variants, counted_on_hand, recent_order_flags)
if not plan:
log.info("Done. Nothing drifted from the counted source.")
return
for row in plan:
log.info(
"SKU %s %s %s -> %s (%s)",
row["sku"], "would set" if DRY_RUN else "setting",
row["fromQty"], row["toQty"], row["reason"],
)
if not DRY_RUN:
submit_adjustments(plan)
confirmed = {v["sku"]: v["inventoryLevel"] for v in all_variants()}
for row in plan:
actual = confirmed.get(row["sku"])
if actual != row["toQty"]:
log.warning("SKU %s did not confirm. Expected %s, saw %s", row["sku"], row["toQty"], actual)
log.info("Done. %d SKU(s) %s.", len(plan), "to adjust" if DRY_RUN else "adjusted")
if __name__ == "__main__":
# Wire counted_on_hand and recent_order_flags up to your WMS/ERP export and
# order-status feed before running for real.
run(counted_on_hand={}, recent_order_flags={})
/**
* Find and safely repair BigCommerce inventory that has drifted from real on-hand counts.
*
* BigCommerce's storefront "available" number is whatever inventory_level currently
* says on the product or variant record, and that number is only as good as the
* last write to it. Orders decrement stock, cancellations and refunds are supposed
* to add it back, and a failed webhook, a mid-flight bulk import through the
* Inventory API, or a manual admin edit can each leave inventory_level out of step
* with what a warehouse recount shows. BigCommerce's own docs warn that the
* Inventory API is "not channel aware" and that running Inventory API bulk
* adjustments in parallel with Catalog or Orders API writes can produce
* unpredictable, incorrect stock calculations, which is exactly the race that
* produces silent drift.
*
* This pulls a counted source of truth (a WMS export, cycle-count CSV, or POS/ERP
* feed), reads every tracked variant's sku and inventory_level from
* GET /v3/catalog/products?include=variants, cross-checks recent order activity
* for cancelled, declined, or refunded orders that were never restocked, and plans
* a set of absolute inventory adjustments with a pure function. Under DRY_RUN it
* only logs the plan. When DRY_RUN is false it submits the batch to
* PUT /v3/inventory/adjustments/absolute and re-reads the variants to confirm the
* counted value stuck. Never run this alongside a concurrent Catalog or Orders API
* write on the same SKUs. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/available-drifts-from-real-on-hand/
*/
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 DEFAULT_LOCATION_ID = Number(process.env.BIGCOMMERCE_LOCATION_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Order statuses (V2) that should have restored stock. If the webhook or app that
// normally restocks a cancelled/declined/refunded order never ran, the SKU is left
// holding a lower inventory_level than reality, and that context gets attached to
// the adjustment reason so a human can see why the drift showed up.
const RESTOCK_STATUS_IDS = new Set([4, 5, 6, 14]); // Refunded, Cancelled, Declined, Partially Refunded
/**
* Pure data transform. No network calls.
*
* catalogVariants: [{ sku, inventoryLevel, inventoryTracking: "none"|"product"|"variant", locationId }]
* countedOnHand: Map<sku, number> true counted quantity from the WMS/cycle count/ERP feed.
* recentOrderFlags: Map<sku, [{ statusId, restocked }]>
*
* Returns [{ sku, locationId, fromQty, toQty, reason }], one per SKU whose counted
* quantity differs from inventory_level.
*
* Only variants with inventoryTracking !== "none" are eligible, since an untracked
* variant has no inventory_level BigCommerce actually enforces. A SKU absent from
* countedOnHand is skipped outright: with no source of truth we do not guess a
* value. When present and different, the record is tagged "cancelled_not_restocked"
* if recentOrderFlags shows a Cancelled(5), Declined(6), Refunded(4), or Partially
* Refunded(14) order for that sku with restocked=false, otherwise "recount_variance".
*/
export function planInventoryReconciliation(catalogVariants, countedOnHand, recentOrderFlags) {
const plan = [];
for (const variant of catalogVariants) {
if (variant.inventoryTracking === "none") continue;
const sku = variant.sku;
if (!countedOnHand.has(sku)) continue;
const toQty = countedOnHand.get(sku);
const fromQty = variant.inventoryLevel;
if (toQty === fromQty) continue;
let reason = "recount_variance";
for (const flag of recentOrderFlags.get(sku) || []) {
if (RESTOCK_STATUS_IDS.has(flag.statusId) && flag.restocked === false) {
reason = "cancelled_not_restocked";
break;
}
}
plan.push({
sku,
locationId: variant.locationId ?? DEFAULT_LOCATION_ID,
fromQty,
toQty,
reason,
});
}
return plan;
}
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;
}
/**
* Read-only. Pages every product with its variants and yields tracked variants
* flattened to { sku, inventoryLevel, inventoryTracking, locationId }.
*/
async function* allVariants() {
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) {
const tracking = product.inventory_tracking;
for (const v of product.variants || []) {
yield {
sku: v.sku,
inventoryLevel: v.inventory_level ?? 0,
inventoryTracking: tracking,
locationId: DEFAULT_LOCATION_ID,
};
}
}
if (batch.length < limit) return;
page += 1;
}
}
/**
* Write path. Sets inventory_level to the counted value in one atomic override per
* SKU using the V3 absolute adjustments endpoint. Up to 2000 items per batch.
*/
async function submitAdjustments(plan) {
const items = plan.map((row) => ({ location_id: row.locationId, sku: row.sku, quantity: row.toQty }));
return bc("PUT", "/v3/inventory/adjustments/absolute", { reason: "reconciliation", items });
}
export async function run(countedOnHand, recentOrderFlags) {
const variants = [];
for await (const v of allVariants()) variants.push(v);
const plan = planInventoryReconciliation(variants, countedOnHand, recentOrderFlags);
if (!plan.length) {
console.log("Done. Nothing drifted from the counted source.");
return;
}
for (const row of plan) {
console.log(
`SKU ${row.sku} ${DRY_RUN ? "would set" : "setting"} ${row.fromQty} -> ${row.toQty} (${row.reason})`
);
}
if (!DRY_RUN) {
await submitAdjustments(plan);
const confirmed = new Map();
for await (const v of allVariants()) confirmed.set(v.sku, v.inventoryLevel);
for (const row of plan) {
const actual = confirmed.get(row.sku);
if (actual !== row.toQty) {
console.warn(`SKU ${row.sku} did not confirm. Expected ${row.toQty}, saw ${actual}`);
}
}
}
console.log(`Done. ${plan.length} SKU(s) ${DRY_RUN ? "to adjust" : "adjusted"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
// Wire countedOnHand and recentOrderFlags up to your WMS/ERP export and
// order-status feed before running for real.
run(new Map(), new Map()).catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The planning rule is the part most worth testing, because it decides which SKUs get overwritten. Because we kept plan_inventory_reconciliation pure, the test needs no network and no BigCommerce store. It just feeds in plain variant, counted, and order-flag data and checks the answer.
from reconcile_inventory import plan_inventory_reconciliation
def variant(**over):
base = {"sku": "SKU-1", "inventoryLevel": 10, "inventoryTracking": "variant", "locationId": 1}
base.update(over)
return base
def test_no_adjustment_when_counted_matches_inventory_level():
plan = plan_inventory_reconciliation([variant()], {"SKU-1": 10}, {})
assert plan == []
def test_skips_sku_with_no_counted_source_of_truth():
plan = plan_inventory_reconciliation([variant()], {}, {})
assert plan == []
def test_skips_untracked_variant():
plan = plan_inventory_reconciliation([variant(inventoryTracking="none")], {"SKU-1": 4}, {})
assert plan == []
def test_flags_recount_variance_when_no_order_context():
plan = plan_inventory_reconciliation([variant()], {"SKU-1": 6}, {})
assert plan == [{"sku": "SKU-1", "locationId": 1, "fromQty": 10, "toQty": 6, "reason": "recount_variance"}]
def test_flags_cancelled_not_restocked_when_order_flag_present():
flags = {"SKU-1": [{"statusId": 5, "restocked": False}]}
plan = plan_inventory_reconciliation([variant()], {"SKU-1": 12}, flags)
assert plan[0]["reason"] == "cancelled_not_restocked"
def test_recount_variance_when_order_was_restocked():
flags = {"SKU-1": [{"statusId": 5, "restocked": True}]}
plan = plan_inventory_reconciliation([variant()], {"SKU-1": 12}, flags)
assert plan[0]["reason"] == "recount_variance"
import { test } from "node:test";
import assert from "node:assert/strict";
import { planInventoryReconciliation } from "./reconcile-inventory.js";
const variant = (over = {}) => ({
sku: "SKU-1",
inventoryLevel: 10,
inventoryTracking: "variant",
locationId: 1,
...over,
});
test("no adjustment when counted matches inventory level", () => {
const plan = planInventoryReconciliation([variant()], new Map([["SKU-1", 10]]), new Map());
assert.deepEqual(plan, []);
});
test("skips sku with no counted source of truth", () => {
const plan = planInventoryReconciliation([variant()], new Map(), new Map());
assert.deepEqual(plan, []);
});
test("skips untracked variant", () => {
const plan = planInventoryReconciliation(
[variant({ inventoryTracking: "none" })],
new Map([["SKU-1", 4]]),
new Map()
);
assert.deepEqual(plan, []);
});
test("flags recount variance when no order context", () => {
const plan = planInventoryReconciliation([variant()], new Map([["SKU-1", 6]]), new Map());
assert.deepEqual(plan, [{ sku: "SKU-1", locationId: 1, fromQty: 10, toQty: 6, reason: "recount_variance" }]);
});
test("flags cancelled not restocked when order flag present", () => {
const flags = new Map([["SKU-1", [{ statusId: 5, restocked: false }]]]);
const plan = planInventoryReconciliation([variant()], new Map([["SKU-1", 12]]), flags);
assert.equal(plan[0].reason, "cancelled_not_restocked");
});
test("recount variance when order was restocked", () => {
const flags = new Map([["SKU-1", [{ statusId: 5, restocked: true }]]]);
const plan = planInventoryReconciliation([variant()], new Map([["SKU-1", 12]]), flags);
assert.equal(plan[0].reason, "recount_variance");
});
Case studies
The warehouse recount that did not match a single report
A home goods store ran a quarterly physical count across its whole catalog. When the WMS export came back, dozens of SKUs disagreed with what BigCommerce showed as available, some higher, some lower, with no obvious pattern. Nobody had touched those products directly, so the team assumed the recount itself was wrong and spent a week re-checking shelves before finding the real cause.
Running the reconciliation script in dry run against the WMS export surfaced the exact list in seconds, tagged with whether each SKU had a recent cancelled or refunded order that never got restocked. Most of the gap traced back to a webhook that had been silently failing on refunds for two months. The absolute adjustment closed the gap in one pass, and the webhook got fixed separately.
The bulk import that raced a busy sale weekend
A seasonal retailer kicked off a bulk Inventory API import to refresh stock counts right as a weekend sale was driving a high volume of orders through the same SKUs. BigCommerce's own warning about the Inventory API not being channel aware turned out to be exactly what happened: several SKUs ended up with numbers that did not match either the pre-import count or what should have sold over the weekend.
The team scheduled reconciliation runs for a quiet overnight window, well clear of any import or heavy order processing, and used the counted feed from their ERP as the tiebreaker. Once the schedule stopped overlapping writes, the same SKUs stopped drifting.
After this runs on a schedule against a real counted feed, a warehouse recount stops being a surprise. Every SKU where BigCommerce's number and the true on-hand count agree is left untouched, every SKU that drifted gets a clean absolute override with the reason attached, and nothing runs while a catalog import or an order surge is touching the same SKUs. The available number on the storefront finally means what it says.
FAQ
Why does BigCommerce show stock available that the warehouse does not actually have?
Because the storefront available number is just inventory_level on the product or variant record, and that field is written by several independent processes: orders decrementing stock, cancellations and refunds that are supposed to add it back, bulk imports through the Inventory API, and manual admin edits. If any one of those writers fails, retries, or races another write, inventory_level drifts from what a physical recount shows, and BigCommerce has no built-in alarm for that gap.
Is it safe to run Inventory API adjustments while Catalog or Orders API writes are also happening?
No. BigCommerce's own documentation warns that the Inventory API is not channel aware and that running Inventory API bulk adjustments in parallel with Catalog or Orders API writes on the same SKUs can produce unpredictable, incorrect stock calculations. Schedule reconciliation runs so they do not overlap with catalog imports or heavy order processing windows on the same SKUs.
How do I find which SKUs have drifted without changing anything?
Pull your counted on-hand quantities from a WMS export, cycle-count CSV, or POS/ERP feed, then call GET /v3/catalog/products?include=variants to read each tracked variant's sku and inventory_level. Compare the two sets and flag any SKU where the counted quantity does not equal inventory_level. This is a read-only comparison, so you can run it safely before deciding whether to write an adjustment.
Related field notes
Citations
On the problem:
- BigCommerce Support: Inventory Tracking. support.bigcommerce.com/s/article/Inventory-Tracking
- BigCommerce Developer Center: Inventory and Location Webhooks. developer.bigcommerce.com/docs/integrations/webhooks/events/inventory-location
- BigCommerce Developer Center: Inventory adjustments. developer.bigcommerce.com/docs/store-operations/catalog/inventory-adjustments
On the solution:
- BigCommerce Developer Center: Inventory Adjustments API reference. developer.bigcommerce.com/docs/rest-management/inventory/adjustments
- BigCommerce REST Management API reference: Inventory. developer.bigcommerce.com/docs/rest-management/settings/inventory
- BigCommerce REST Management API reference: Orders (V2), status_id and order lifecycle. developer.bigcommerce.com/docs/rest-management/orders
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 some drifted stock?
If this closed a gap between your warehouse and your storefront, 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