Repair Inventory

Concurrent inventory and catalog or order bulk jobs corrupt stock totals

Two bulk jobs, one SKU, one location, the same window of time. An inventory adjustment job and a catalog or order bulk job both touch the same underlying stock counter, and BigCommerce's own documentation warns this can produce unpredictable, incorrect results. The two writes race, one silently clobbers or double-applies the other, and total_inventory_onhand quietly drifts from what your store actually has. Here is why the race happens and a script that detects the drift against your own adjustment ledger and repairs it safely.

Python and Node.js BigCommerce V3 Inventory API Safe by default (dry run)
A large warehouse filled with lots of shelves
Photo by Lance Chang on Unsplash
The short answer

BigCommerce's Inventory API processes absolute and relative adjustments asynchronously through its own internal queue, and its documentation explicitly warns that running Inventory API bulk operations in parallel with Catalog API or Orders API bulk operations "may cause unpredictable, incorrect calculation results." Relative adjustments do a read-modify-write against the current stored value, so a catalog bulk edit that also touches inventory_level, or an order bulk job decrementing stock, can race an inventory adjustment job on the same SKU and location and clobber it. Serialize the jobs with a per-store lock going forward, then reconcile the damage already done: rebuild each SKU and location's expected on-hand from your own adjustment ledger, compare it against GET /v3/inventory/locations/{location_id}/items, and push a corrective PUT /v3/inventory/adjustments/absolute only where the two disagree beyond a tolerance. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce keeps one number per SKU and location, total_inventory_onhand, and every inventory adjustment call is really a write against that single counter. Absolute adjustments overwrite it outright. Relative adjustments have to read the current value, add or subtract the delta, and write the result back, all inside BigCommerce's own asynchronous queue.

That queue does not know, and does not care, whether something else is also writing to the same SKU right now. A catalog bulk job that edits products or variants and happens to touch inventory_level along the way, or an order bulk job that is decrementing stock for a batch of fulfillments, can land a write on the exact same SKU and location in the exact same window as an inventory adjustment job. Both jobs believe they are the only one updating that counter. One of them wins, the other's change is silently lost or double-applied, and nobody gets an error for it. There is also a documented propagation delay between an adjustment call returning 200 and the new value being reliably readable via GET, which widens the window and lets a second job read stale data mid-race.

Inventory adjustment job relative, SKU-123 @ loc 1 Catalog or order bulk job also writes SKU-123 @ loc 1 Same counter, same window, internal queue Race, one write wins total_inventory_onhand wrong for one write
Two bulk jobs write the same SKU and location counter in the same window. BigCommerce's own docs warn the results are unpredictable when this happens.

Why it happens

BigCommerce's documentation is direct about this: Inventory API bulk operations run in parallel with Catalog API or Orders API bulk operations "may cause unpredictable, incorrect calculation results." A few concrete ways this plays out on real stores:

None of this raises an error. Both jobs get a 200. The drift only shows up later, as a mismatch between what the store thinks it has and what a human counts on a shelf. See the citations at the end for BigCommerce's own concurrency warning and the underlying adjustments reference.

The key insight

total_inventory_onhand is not a ledger, it is a single number that the last write happened to leave behind. The only reliable ledger is the one you keep yourself: every relative and absolute adjustment your own integration issues, with a timestamp and a SKU and location. BigCommerce does not expose a public adjustment audit-trail endpoint, so you cannot ask the platform what sequence of writes produced the current value. You have to reconstruct the expected on-hand from your own log and compare it against what BigCommerce actually reports, then treat any mismatch beyond a small tolerance as corrupted.

The fix, as a flow

We do not touch the live checkout or catalog editing flow. We add a job that first serializes future runs behind a per-store lock so this stops happening, then walks every SKU and location touched during the overlapping window, diffs the actual total against the expected total from our own ledger, and pushes a single corrective absolute adjustment only where the two disagree.

Reconciliation job runs after the overlap List touched SKUs products, variants, locations Read actual on-hand inventory items endpoint Differs from ledger total? yes no, already correct Absolute adjustment then re-verify
The job only writes a correction where the actual on-hand disagrees with our own ledger's expected total, and it re-verifies the write before marking the SKU reconciled.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Inventory (modify) and Products (read) scope so it can read inventory items and push corrective adjustments. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export STOCK_TOLERANCE="0"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

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

Talk to the V3 Inventory and Catalog REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to list recently touched products and variants, read current inventory items, and write the corrective absolute adjustment.

step2.py
import os, requests

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

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

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()

def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

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

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

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

Find the SKUs touched during the overlap window and read their actual on-hand

Call GET /v3/catalog/products?include=variants&date_modified:min={job_start_ts}, paginated, to find products and variants touched since the overlapping jobs started. For each SKU and location pair, call GET /v3/inventory/locations/{location_id}/items?sku__in={skus} to read the current total_inventory_onhand and available_to_sell straight from BigCommerce.

step3.py
def touched_products(job_start_ts):
    page = 1
    while True:
        resp = bc_get("/catalog/products", {
            "include": "variants",
            "date_modified:min": job_start_ts,
            "page": page,
            "limit": 250,
        })
        rows = resp.get("data", [])
        if not rows:
            return
        for product in rows:
            yield product
        page += 1

def actual_inventory_items(location_id, skus):
    resp = bc_get(f"/inventory/locations/{location_id}/items", {"sku__in": ",".join(skus)})
    return resp.get("data", [])
step3.js
async function* touchedProducts(jobStartTs) {
  let page = 1;
  while (true) {
    const resp = await bcGet("/catalog/products", {
      include: "variants",
      "date_modified:min": jobStartTs,
      page,
      limit: 250,
    });
    const rows = resp.data || [];
    if (!rows.length) return;
    for (const product of rows) yield product;
    page += 1;
  }
}

async function actualInventoryItems(locationId, skus) {
  const resp = await bcGet(`/inventory/locations/${locationId}/items`, { sku__in: skus.join(",") });
  return resp.data || [];
}
4

Decide, with one pure function

Keep the corruption decision in its own function that takes the actual on-hand BigCommerce reports, the expected on-hand from your own adjustment ledger, and a tolerance, and returns a plain boolean. Anything beyond the tolerance gets flagged for repair. This is the only place the "is it corrupted" judgment lives, so it can be tested with plain numbers and no store.

decide.py
def is_inventory_corrupted(actual_on_hand: int, expected_on_hand: int, tolerance: int = 0) -> bool:
    return abs(actual_on_hand - expected_on_hand) > tolerance
decide.js
export function isInventoryCorrupted(actualOnHand, expectedOnHand, tolerance = 0) {
  return Math.abs(actualOnHand - expectedOnHand) > tolerance;
}
5

Build the correction payload, then write it and re-verify

When a SKU and location are flagged, build the exact item dict the absolute-adjustment endpoint expects with a second pure helper, no network involved. Under DRY_RUN=true, only log that payload. Under DRY_RUN=false, call PUT /v3/inventory/adjustments/absolute with reason reconciliation-after-concurrent-jobs, batched up to 2,000 items per BigCommerce's documented limit, then immediately re-read the same SKUs and confirm total_inventory_onhand equals the pushed quantity before marking anything reconciled.

apply.py
def build_correction_payload(sku: str, location_id: int, expected_on_hand: int) -> dict:
    return {"location_id": location_id, "sku": sku, "quantity": expected_on_hand}

def push_absolute_adjustment(items):
    body = {"reason": "reconciliation-after-concurrent-jobs", "items": items}
    return bc_put("/inventory/adjustments/absolute", body)
apply.js
export function buildCorrectionPayload(sku, locationId, expectedOnHand) {
  return { location_id: locationId, sku, quantity: expectedOnHand };
}

async function pushAbsoluteAdjustment(items) {
  const body = { reason: "reconciliation-after-concurrent-jobs", items };
  return bcPut("/inventory/adjustments/absolute", body);
}
6

Wire it together with a dry run guard and a store-wide mutex

The loop ties every piece together, batching corrections up to 2,000 items per call. Notice the dry run guard: on the first run, leave DRY_RUN on so the script only logs the {sku, location_id, actual_on_hand, expected_on_hand, corrective_quantity} tuple for every flagged SKU. Read the output, agree with it, then switch it off. Just as important, gate every future inventory-adjustment job and catalog or order bulk job behind a single per-store_hash mutex, a Redis or DB lock, so they never overlap again after this cleanup.

Run it safe

Always start with DRY_RUN=true, and always re-verify a write with a fresh GET before marking a SKU reconciled. A naive re-apply without re-verification can double-correct a SKU that a concurrent job has already settled, because absolute and relative adjustments can still race in either order until the mutex is in place.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only writes a correction where the actual on-hand disagrees with your own ledger's expected total beyond the tolerance, and it re-verifies every write before calling a SKU reconciled.

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

reconcile_concurrent_inventory_drift.py
"""Detect and repair BigCommerce stock corrupted by concurrent bulk jobs.

BigCommerce's Inventory API processes absolute and relative adjustments
asynchronously through its own internal queue, and its documentation warns
that running Inventory API bulk operations in parallel with Catalog API or
Orders API bulk operations "may cause unpredictable, incorrect calculation
results." Relative adjustments do a read-modify-write against the current
stored total_inventory_onhand, so a catalog bulk edit that also touches
inventory_level, or an order bulk job decrementing stock, can race an
inventory adjustment job on the same SKU and location and silently clobber
or double-apply it. There is also a documented propagation delay between an
adjustment call returning 200 and the new value being reliably readable via
GET, which widens the race window.

BigCommerce does not expose a public adjustment audit-trail endpoint, so
this job reconstructs the expected on-hand for each SKU and location from
the integration's own adjustment ledger, compares it against the actual
total_inventory_onhand BigCommerce reports, and pushes a corrective
absolute adjustment only where the two disagree beyond a tolerance. Every
write is re-verified with a fresh GET before the SKU is marked reconciled.
Run once after any window where inventory and catalog or order bulk jobs
overlapped, then gate all future jobs behind a per-store_hash mutex so this
does not happen again.

Guide: https://www.allanninal.dev/bigcommerce/concurrent-inventory-catalog-jobs-corrupt-stock/
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
STOCK_TOLERANCE = int(os.environ.get("STOCK_TOLERANCE", "0"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

MAX_ITEMS_PER_ADJUSTMENT_CALL = 2000

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


def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()


def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def is_inventory_corrupted(actual_on_hand: int, expected_on_hand: int, tolerance: int = 0) -> bool:
    """Pure decision. No network, no side effects.

    Returns True (flag for repair) when the actual on-hand BigCommerce
    reports differs from the expected on-hand reconstructed from our own
    adjustment ledger by more than tolerance. Returns False otherwise.
    """
    return abs(actual_on_hand - expected_on_hand) > tolerance


def build_correction_payload(sku: str, location_id: int, expected_on_hand: int) -> dict:
    """Pure payload builder. No network, no side effects.

    Returns the exact item dict the absolute-adjustment request body
    expects for one SKU and location.
    """
    return {"location_id": location_id, "sku": sku, "quantity": expected_on_hand}


def touched_products(job_start_ts):
    """Page through products and variants modified since job_start_ts."""
    page = 1
    while True:
        resp = bc_get(
            "/catalog/products",
            {
                "include": "variants",
                "date_modified:min": job_start_ts,
                "page": page,
                "limit": 250,
            },
        )
        rows = resp.get("data", [])
        if not rows:
            return
        for product in rows:
            yield product
        page += 1


def actual_inventory_items(location_id, skus):
    """Read current total_inventory_onhand for a batch of SKUs at a location."""
    if not skus:
        return []
    resp = bc_get(f"/inventory/locations/{location_id}/items", {"sku__in": ",".join(skus)})
    return resp.get("data", [])


def expected_on_hand_from_ledger(sku, location_id, ledger):
    """Reconstruct expected on-hand from our own adjustment log.

    ledger maps (sku, location_id) -> expected on-hand, built from a
    baseline plus every relative or absolute adjustment this integration
    issued during the overlapping window. BigCommerce does not expose a
    public adjustment audit-trail endpoint, so this ledger has to be kept
    by the integration itself.
    """
    return ledger.get((sku, location_id))


def push_absolute_adjustment(items):
    body = {"reason": "reconciliation-after-concurrent-jobs", "items": items}
    return bc_put("/inventory/adjustments/absolute", body)


def batched(seq, size):
    for i in range(0, len(seq), size):
        yield seq[i : i + size]


def run(job_start_ts, ledger):
    """ledger: dict mapping (sku, location_id) -> expected on-hand int.

    In production this is built from the integration's own persisted
    adjustment history, not passed in by hand.
    """
    flagged = []

    for product in touched_products(job_start_ts):
        variants = product.get("variants") or [{
            "sku": product.get("sku"),
            "inventory_level": product.get("inventory_level"),
        }]
        for variant in variants:
            sku = variant.get("sku")
            if not sku:
                continue
            for location_id in sorted({loc for (s, loc) in ledger if s == sku}):
                items = actual_inventory_items(location_id, [sku])
                for item in items:
                    actual = item.get("total_inventory_onhand")
                    expected = expected_on_hand_from_ledger(sku, location_id, ledger)
                    if actual is None or expected is None:
                        continue
                    if is_inventory_corrupted(actual, expected, STOCK_TOLERANCE):
                        flagged.append((sku, location_id, actual, expected))

    log.info("Found %d SKU/location pair(s) with drift beyond tolerance %d.", len(flagged), STOCK_TOLERANCE)

    corrected = 0
    for batch in batched(flagged, MAX_ITEMS_PER_ADJUSTMENT_CALL):
        payload_items = [
            build_correction_payload(sku, location_id, expected)
            for (sku, location_id, actual, expected) in batch
        ]
        for (sku, location_id, actual, expected) in batch:
            log.info(
                "sku=%s location_id=%s actual_on_hand=%s expected_on_hand=%s (%s)",
                sku, location_id, actual, expected,
                "dry run" if DRY_RUN else "correcting",
            )
        if DRY_RUN:
            continue

        push_absolute_adjustment(payload_items)

        skus_in_batch = [sku for (sku, _loc, _a, _e) in batch]
        by_location = {}
        for (sku, location_id, _actual, expected) in batch:
            by_location.setdefault(location_id, []).append((sku, expected))

        for location_id, sku_expected in by_location.items():
            skus = [sku for (sku, _e) in sku_expected]
            verify_items = actual_inventory_items(location_id, skus)
            verify_by_sku = {i.get("sku"): i.get("total_inventory_onhand") for i in verify_items}
            for sku, expected in sku_expected:
                if verify_by_sku.get(sku) == expected:
                    corrected += 1
                else:
                    log.warning(
                        "Re-verify failed for sku=%s location_id=%s expected=%s got=%s",
                        sku, location_id, expected, verify_by_sku.get(sku),
                    )

    log.info(
        "Done. %d SKU/location pair(s) %s.",
        len(flagged), "would be corrected" if DRY_RUN else f"corrected ({corrected} re-verified)",
    )


if __name__ == "__main__":
    raise SystemExit(
        "This script needs job_start_ts and a persisted adjustment ledger from your own "
        "integration. Import run(job_start_ts, ledger) and call it from your scheduler."
    )
reconcile-concurrent-inventory-drift.js
/**
 * Detect and repair BigCommerce stock corrupted by concurrent bulk jobs.
 *
 * BigCommerce's Inventory API processes absolute and relative adjustments
 * asynchronously through its own internal queue, and its documentation warns
 * that running Inventory API bulk operations in parallel with Catalog API or
 * Orders API bulk operations "may cause unpredictable, incorrect calculation
 * results." Relative adjustments do a read-modify-write against the current
 * stored total_inventory_onhand, so a catalog bulk edit that also touches
 * inventory_level, or an order bulk job decrementing stock, can race an
 * inventory adjustment job on the same SKU and location and silently clobber
 * or double-apply it. There is also a documented propagation delay between an
 * adjustment call returning 200 and the new value being reliably readable via
 * GET, which widens the race window.
 *
 * BigCommerce does not expose a public adjustment audit-trail endpoint, so
 * this job reconstructs the expected on-hand for each SKU and location from
 * the integration's own adjustment ledger, compares it against the actual
 * total_inventory_onhand BigCommerce reports, and pushes a corrective
 * absolute adjustment only where the two disagree beyond a tolerance. Every
 * write is re-verified with a fresh GET before the SKU is marked reconciled.
 * Gate all future inventory and catalog or order bulk jobs behind a single
 * per-store_hash mutex so this does not happen again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/concurrent-inventory-catalog-jobs-corrupt-stock/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const STOCK_TOLERANCE = Number(process.env.STOCK_TOLERANCE || 0);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const MAX_ITEMS_PER_ADJUSTMENT_CALL = 2000;

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

/**
 * Pure decision. No network, no side effects.
 *
 * Returns true (flag for repair) when the actual on-hand BigCommerce
 * reports differs from the expected on-hand reconstructed from our own
 * adjustment ledger by more than tolerance. Returns false otherwise.
 */
export function isInventoryCorrupted(actualOnHand, expectedOnHand, tolerance = 0) {
  return Math.abs(actualOnHand - expectedOnHand) > tolerance;
}

/**
 * Pure payload builder. No network, no side effects.
 *
 * Returns the exact item object the absolute-adjustment request body
 * expects for one SKU and location.
 */
export function buildCorrectionPayload(sku, locationId, expectedOnHand) {
  return { location_id: locationId, sku, quantity: expectedOnHand };
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

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

async function* touchedProducts(jobStartTs) {
  let page = 1;
  while (true) {
    const resp = await bcGet("/catalog/products", {
      include: "variants",
      "date_modified:min": jobStartTs,
      page,
      limit: 250,
    });
    const rows = resp.data || [];
    if (!rows.length) return;
    for (const product of rows) yield product;
    page += 1;
  }
}

async function actualInventoryItems(locationId, skus) {
  if (!skus.length) return [];
  const resp = await bcGet(`/inventory/locations/${locationId}/items`, { sku__in: skus.join(",") });
  return resp.data || [];
}

/**
 * Reconstruct expected on-hand from our own adjustment log.
 *
 * ledger maps a "sku|locationId" key to the expected on-hand, built from a
 * baseline plus every relative or absolute adjustment this integration
 * issued during the overlapping window. BigCommerce does not expose a
 * public adjustment audit-trail endpoint, so this ledger has to be kept by
 * the integration itself.
 */
function expectedOnHandFromLedger(sku, locationId, ledger) {
  return ledger.get(`${sku}|${locationId}`);
}

async function pushAbsoluteAdjustment(items) {
  const body = { reason: "reconciliation-after-concurrent-jobs", items };
  return bcPut("/inventory/adjustments/absolute", body);
}

function* batched(seq, size) {
  for (let i = 0; i < seq.length; i += size) yield seq.slice(i, i + size);
}

/**
 * ledger: Map of "sku|locationId" -> expected on-hand number.
 *
 * In production this is built from the integration's own persisted
 * adjustment history, not passed in by hand.
 */
export async function run(jobStartTs, ledger) {
  const flagged = [];
  const locationIdsBySku = new Map();
  for (const key of ledger.keys()) {
    const [sku, locationIdStr] = key.split("|");
    const locationId = Number(locationIdStr);
    if (!locationIdsBySku.has(sku)) locationIdsBySku.set(sku, []);
    locationIdsBySku.get(sku).push(locationId);
  }

  for await (const product of touchedProducts(jobStartTs)) {
    const variants = product.variants && product.variants.length
      ? product.variants
      : [{ sku: product.sku, inventory_level: product.inventory_level }];

    for (const variant of variants) {
      const sku = variant.sku;
      if (!sku) continue;
      const locationIds = locationIdsBySku.get(sku) || [];
      for (const locationId of locationIds) {
        const items = await actualInventoryItems(locationId, [sku]);
        for (const item of items) {
          const actual = item.total_inventory_onhand;
          const expected = expectedOnHandFromLedger(sku, locationId, ledger);
          if (actual == null || expected == null) continue;
          if (isInventoryCorrupted(actual, expected, STOCK_TOLERANCE)) {
            flagged.push({ sku, locationId, actual, expected });
          }
        }
      }
    }
  }

  console.log(`Found ${flagged.length} SKU/location pair(s) with drift beyond tolerance ${STOCK_TOLERANCE}.`);

  let corrected = 0;
  for (const batch of batched(flagged, MAX_ITEMS_PER_ADJUSTMENT_CALL)) {
    const payloadItems = batch.map((f) => buildCorrectionPayload(f.sku, f.locationId, f.expected));

    for (const f of batch) {
      console.log(
        `sku=${f.sku} location_id=${f.locationId} actual_on_hand=${f.actual} expected_on_hand=${f.expected} ` +
        `(${DRY_RUN ? "dry run" : "correcting"})`
      );
    }
    if (DRY_RUN) continue;

    await pushAbsoluteAdjustment(payloadItems);

    const byLocation = new Map();
    for (const f of batch) {
      if (!byLocation.has(f.locationId)) byLocation.set(f.locationId, []);
      byLocation.get(f.locationId).push(f);
    }

    for (const [locationId, entries] of byLocation) {
      const skus = entries.map((e) => e.sku);
      const verifyItems = await actualInventoryItems(locationId, skus);
      const verifyBySku = new Map(verifyItems.map((i) => [i.sku, i.total_inventory_onhand]));
      for (const e of entries) {
        if (verifyBySku.get(e.sku) === e.expected) {
          corrected += 1;
        } else {
          console.warn(
            `Re-verify failed for sku=${e.sku} location_id=${locationId} expected=${e.expected} got=${verifyBySku.get(e.sku)}`
          );
        }
      }
    }
  }

  console.log(
    `Done. ${flagged.length} SKU/location pair(s) ${DRY_RUN ? "would be corrected" : `corrected (${corrected} re-verified)`}.`
  );
}

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

Add a test

The corruption decision and the payload shape are the two parts most worth testing, because together they decide whether a real inventory count gets overwritten. Both is_inventory_corrupted and build_correction_payload take only plain values and return plain values, so the tests need no network and no BigCommerce store.

test_concurrent_stock_drift.py
from reconcile_concurrent_inventory_drift import (
    is_inventory_corrupted,
    build_correction_payload,
)


def test_not_corrupted_when_actual_matches_expected():
    assert is_inventory_corrupted(50, 50) is False


def test_not_corrupted_within_tolerance():
    assert is_inventory_corrupted(48, 50, tolerance=2) is False


def test_corrupted_when_actual_drifts_above_tolerance():
    assert is_inventory_corrupted(45, 50, tolerance=2) is True


def test_corrupted_when_actual_is_higher_than_expected():
    assert is_inventory_corrupted(70, 50) is True


def test_correction_payload_has_exact_shape():
    payload = build_correction_payload("SKU-123", 7, 50)
    assert payload == {"location_id": 7, "sku": "SKU-123", "quantity": 50}


def test_correction_payload_uses_expected_on_hand_as_quantity():
    payload = build_correction_payload("SKU-999", 1, 0)
    assert payload["quantity"] == 0
reconcile-concurrent-inventory-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isInventoryCorrupted, buildCorrectionPayload } from "./reconcile-concurrent-inventory-drift.js";

test("not corrupted when actual matches expected", () => {
  assert.equal(isInventoryCorrupted(50, 50), false);
});

test("not corrupted within tolerance", () => {
  assert.equal(isInventoryCorrupted(48, 50, 2), false);
});

test("corrupted when actual drifts above tolerance", () => {
  assert.equal(isInventoryCorrupted(45, 50, 2), true);
});

test("corrupted when actual is higher than expected", () => {
  assert.equal(isInventoryCorrupted(70, 50), true);
});

test("correction payload has exact shape", () => {
  const payload = buildCorrectionPayload("SKU-123", 7, 50);
  assert.deepEqual(payload, { location_id: 7, sku: "SKU-123", quantity: 50 });
});

test("correction payload uses expected on hand as quantity", () => {
  const payload = buildCorrectionPayload("SKU-999", 1, 0);
  assert.equal(payload.quantity, 0);
});

Case studies

Nightly import overlap

The store whose nightly product import fought its own inventory sync

A mid-size store ran a nightly catalog import to update pricing and descriptions, and a separate order-driven inventory sync that decremented stock as orders came in. Both jobs happened to overlap for about twenty minutes every night, and both touched inventory_level on the same variants. Staff kept finding SKUs that showed more or less stock than a warehouse count agreed with, with no error anywhere in either job's logs.

The reconciliation job ran once against the affected window, rebuilt expected on-hand from the sync's own adjustment log, and found the drift on about three percent of touched SKUs. After pushing the corrections and re-verifying each one, the two jobs were put behind a shared per-store_hash lock so the import always waits for the sync to finish, and the drift never came back.

Bulk order fulfillment

The fulfillment batch that raced a bulk variant relaunch

A merchant relaunched a whole product line's variants in one bulk catalog operation on the same afternoon a large batch of backordered items shipped and decremented stock through the Orders API. Several SKUs ended up showing available_to_sell numbers that did not match what the warehouse had just counted, right on the variants the relaunch had touched.

Because the store already logged every adjustment it issued, the reconciliation job could reconstruct the correct expected total for each affected SKU without guessing. It flagged the mismatches, corrected them with a single batched absolute adjustment, and confirmed every one with a fresh GET before calling it done.

What good looks like

After the reconciliation job runs, every SKU and location touched during the overlap window has an actual total_inventory_onhand that matches your own ledger's expected total within tolerance, and every correction that was written has been re-verified with a fresh GET before being marked done. Going forward, the per-store_hash mutex means inventory adjustment jobs and catalog or order bulk jobs simply never run at the same time again, so the race that caused the drift cannot recur.

FAQ

Why do stock totals get corrupted when I run inventory and catalog jobs at the same time?

BigCommerce's Inventory API processes adjustments asynchronously with its own internal queue, and the documentation explicitly warns that running Inventory API bulk operations in parallel with Catalog API or Orders API bulk operations may cause unpredictable, incorrect calculation results. Relative adjustments read the current stored value before writing a new one, so if a catalog bulk edit or an order job touches the same SKU and location in that window, the two writes race on the same counter and one can silently clobber or double-apply the other.

Is it safe to auto-write a correction the moment I detect drift?

No. Correction should be a serialize-then-reconcile flow, not a blind auto-write, because concurrent absolute and relative adjustments can race in either order and a naive re-apply could double-correct a SKU that has already settled. Recompute the expected on-hand from your own adjustment ledger, push a single absolute adjustment, then re-verify the value before marking the SKU reconciled.

Does BigCommerce expose an audit trail I can use to reconstruct what happened?

No. BigCommerce does not expose a public adjustment audit-trail endpoint, so you cannot ask the platform what sequence of writes produced a given total_inventory_onhand value. You have to keep your own ledger of every relative and absolute adjustment your integration issues, and diff each SKU and location's actual on-hand against that ledger to find drift.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Adjustments (inventory API overview, concurrency caveat). developer.bigcommerce.com inventory adjustments
  2. BigCommerce Docs: Inventory adjustments (store-operations guide). developer.bigcommerce.com store-operations inventory adjustments
  3. BigCommerce Support Community: API to update Inventory. support.bigcommerce.com api to update inventory

On the solution:

  1. BigCommerce Developer Center: Adjustments (absolute vs relative endpoints, batching, sequencing guidance). developer.bigcommerce.com inventory adjustments
  2. BigCommerce Developer Center: Inventory (GET locations/{location_id}/items, total_inventory_onhand, available_to_sell fields). developer.bigcommerce.com inventory (BOPIS)
  3. BigCommerce Docs: Inventory (REST admin management inventory reference). docs.bigcommerce.com inventory reference

Stuck on a tricky one?

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

Contact me on LinkedIn

Did this stop your stock from drifting?

If this caught corrupted counts you would have otherwise found by hand or on a shelf, 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