Diagnostic Inventory

Inventory read immediately after write returns stale stock

You PUT an inventory adjustment, BigCommerce answers 200 with an action id, and your very next GET still shows the old quantity. Nothing errored. Nothing looks wrong. The Inventory API commits and propagates writes asynchronously, so a read that lands too soon can see the pre-write stock with no signal that it is stale. Here is why that gap opens up and a small script that polls for confirmation with backoff and flags, never silently re-submits, anything that never catches up.

Python and Node.js BigCommerce V3 Inventory API Safe by default (dry run)
Bicycle parked in a cluttered warehouse with a vintage car.
Photo by Rana Kaname on Unsplash
The short answer

BigCommerce's Inventory API (PUT /v3/inventory/adjustments/absolute or /relative) is eventually consistent. The 200 response and its data.id action identifier confirm the write was accepted into the processing pipeline, not that the new quantity is already durable and visible on the read path. A GET against /v3/inventory/items or a variant's stock fields immediately after can still return the old number, with no error to tell you so. Run a small Python or Node.js script that, after every adjustment, polls the read endpoint with exponential backoff until the observed quantity matches what you expect, and if it never matches within the retry budget, flags the adjustment for an operator instead of re-submitting the write. Full code, tests, and a dry run guard are below.

The problem in plain words

When you call PUT /v3/inventory/adjustments/absolute or /v3/inventory/adjustments/relative, BigCommerce hands back a 200 with a top-level data.id, an action identifier, and each item's echoed identity (its sku, product_id, or variant_id) and location_id. That response tells you the request was accepted. It does not tell you the new quantity has finished writing through to wherever a GET on /v3/inventory/items or a product's variant stock field reads from.

BigCommerce's own documentation is direct about this: the Inventory API is eventually consistent, and "there may be a short delay before data is updated after the endpoints are called." For most integrations that delay is invisible, a few hundred milliseconds nobody notices. But a script that writes an adjustment and immediately reads it back to confirm, log, or chain the next step can catch the window where the old value is still what comes back. Nothing in that GET response says "this is stale." It looks exactly like a normal, successful read of the current stock, except it is not current yet.

PUT adjustment absolute or relative 200 + data.id accepted, not committed still propagating GET /v3/inventory reads old quantity Looks like a real value
The write is accepted before it is durable. A read that lands in that gap sees the pre-write quantity with no error, no flag, nothing to say it is stale.

Why it happens

BigCommerce's Inventory API accepts an adjustment into a processing pipeline and returns as soon as the request is validated and queued, not after the new quantity is durably committed and propagated to every read path. A few concrete ways this bites real integrations:

The key insight

A 200 from /v3/inventory/adjustments means the write was accepted, not that it is visible yet. The only way to know a write has actually landed is to read it back and compare against the quantity you expect, retrying with backoff until it matches or you run out of budget. And because this is a timing problem, not a data problem, the correct response to an unconfirmed write is never to resubmit the adjustment. Re-issuing a relative delta against a write that may have already applied risks double-counting the change, and it can mask a real failure downstream that deserves a human's attention instead of a silent retry of the write itself.

The fix, as a flow

We do not change how adjustments are submitted. We wrap every adjustment with a confirmation loop: submit the write, then poll the read endpoint with exponential backoff until the observed quantity matches the expected quantity, honoring BigCommerce's rate limit headers along the way. If it never matches, we flag the adjustment with everything an operator needs, and we never call /v3/inventory/adjustments again to "fix" it.

Submit adjustment capture data.id Poll the read path GET inventory items Compare quantity observed vs expected Matches or budget left? matches no match, retry backoff stale_flagged budget exhausted confirmed read matches write
Confirm never resubmit. A write is either confirmed by a matching read, or it exhausts its poll budget and gets flagged for an operator, never a second call to the adjustments endpoint.

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) scope so it can submit adjustments and read stock levels. 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 MAX_ATTEMPTS="6"
export BASE_DELAY_S="1.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 MAX_ATTEMPTS="6"
export BASE_DELAY_S="1.0"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V3 Inventory REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to submit the adjustment and to poll the item back.

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() if r.text else {}

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

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

Submit the adjustment and capture what you need to confirm it

Call PUT /v3/inventory/adjustments/absolute (or /relative) with a reason and an items array of {"variant_id" or "product_id" or "sku", "location_id", "quantity"}. From the 200 response, keep the top-level data.id action identifier and each item's echoed identity and location_id, everything the poll step needs to look the write back up.

step3.py
def submit_adjustment(mode, reason, sku, location_id, quantity):
    """mode is 'absolute' or 'relative'."""
    body = {
        "reason": reason,
        "items": [{"sku": sku, "location_id": location_id, "quantity": quantity}],
    }
    return bc_put(f"/inventory/adjustments/{mode}", body)

def read_item(sku, location_id):
    data = bc_get("/inventory/items", {"location_ids": location_id, "skus": sku})
    rows = data.get("data") or []
    return rows[0] if rows else None
step3.js
async function submitAdjustment(mode, reason, sku, locationId, quantity) {
  // mode is "absolute" or "relative"
  const body = {
    reason,
    items: [{ sku, location_id: locationId, quantity }],
  };
  return bcPut(`/inventory/adjustments/${mode}`, body);
}

async function readItem(sku, locationId) {
  const data = await bcGet("/inventory/items", { location_ids: locationId, skus: sku });
  const rows = data.data || [];
  return rows.length ? rows[0] : null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the expected quantity, the observed quantity from the latest poll, the adjustment id, the current attempt, and the retry budget, and returns one of three outcomes. It has no I/O of its own, so it is trivial to test: feed it plain numbers and check the answer.

decide.py
def confirm_inventory_write(
    expected_quantity, observed_quantity, adjustment_id, attempt, max_attempts,
    base_delay_s=1.0, max_delay_s=60.0,
):
    if adjustment_id is None:
        return {"status": "stale_flagged", "next_delay_s": None,
                "reason": "missing action id, cannot confirm"}
    if observed_quantity == expected_quantity:
        return {"status": "confirmed", "next_delay_s": None, "reason": "quantity matches"}
    if attempt >= max_attempts:
        return {"status": "stale_flagged", "next_delay_s": None,
                "reason": "poll budget exhausted"}
    delay = min(base_delay_s * (2 ** attempt), max_delay_s)
    return {"status": "retry", "next_delay_s": delay, "reason": "quantity not yet confirmed"}
decide.js
function confirmInventoryWrite(
  expectedQuantity, observedQuantity, adjustmentId, attempt, maxAttempts,
  baseDelayS = 1.0, maxDelayS = 60.0,
) {
  if (adjustmentId === null || adjustmentId === undefined) {
    return { status: "stale_flagged", nextDelayS: null, reason: "missing action id, cannot confirm" };
  }
  if (observedQuantity === expectedQuantity) {
    return { status: "confirmed", nextDelayS: null, reason: "quantity matches" };
  }
  if (attempt >= maxAttempts) {
    return { status: "stale_flagged", nextDelayS: null, reason: "poll budget exhausted" };
  }
  const delay = Math.min(baseDelayS * 2 ** attempt, maxDelayS);
  return { status: "retry", nextDelayS: delay, reason: "quantity not yet confirmed" };
}
5

Poll with backoff, honoring the rate limit headers

Wrap the read in a loop that calls the pure decision function after each attempt. Respect X-Rate-Limit-Remaining or X-Rate-Limit-Requests-Left if BigCommerce sends them back, so a retry storm on a single item never eats into the budget the rest of the job needs.

poll.py
import time

def poll_until_confirmed(sku, location_id, expected_quantity, adjustment_id, max_attempts):
    attempt = 0
    while True:
        item = read_item(sku, location_id)
        observed = item.get("available_to_sell") if item else None
        decision = confirm_inventory_write(
            expected_quantity, observed, adjustment_id, attempt, max_attempts
        )
        if decision["status"] != "retry":
            return decision, observed, attempt
        time.sleep(decision["next_delay_s"])
        attempt += 1
poll.js
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function pollUntilConfirmed(sku, locationId, expectedQuantity, adjustmentId, maxAttempts) {
  let attempt = 0;
  while (true) {
    const item = await readItem(sku, locationId);
    const observed = item ? item.available_to_sell : null;
    const decision = confirmInventoryWrite(expectedQuantity, observed, adjustmentId, attempt, maxAttempts);
    if (decision.status !== "retry") return { decision, observed, attempt };
    await sleep(decision.nextDelayS * 1000);
    attempt += 1;
  }
}
6

Wire it together with a dry run guard

The loop ties every piece together: submit, poll, and either move on or flag. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script logs the {adjustment_id, sku/variant_id, location_id, expected_quantity, last_observed_quantity, attempts, elapsed_ms} record for anything it would flag, without ever calling the adjustments endpoint a second time. If a write never confirms, the only allowed follow-up action is one more GET on a longer final backoff, never a PUT.

Run it safe

Never resubmit an adjustment to "fix" a stale read. A relative adjustment resubmitted on top of one that already applied can double the change, and it can hide a genuine downstream failure that needs a human, not a retry. Confirm by reading, escalate by flagging, and leave the write path alone.

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 issues a second write for the same adjustment; it only polls and, if the poll budget runs out, flags the adjustment for an operator.

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

confirm_inventory_write.py
"""Confirm BigCommerce inventory adjustments instead of trusting the write's 200.

BigCommerce's Inventory API (PUT /v3/inventory/adjustments/absolute or /relative)
processes writes asynchronously. The call returns 200 with an action id (data.id)
as soon as the request is accepted into the processing pipeline, not after the new
quantity is durably committed and propagated to the read path. BigCommerce's own
docs describe this as eventual consistency: "there may be a short delay before
data is updated after the endpoints are called." A relative adjustment can even
race against a still-in-flight absolute adjustment's pre-check stage and apply
the pre-adjustment value. A GET immediately after a write can therefore return
the pre-write quantity with no error or signal that it is stale.

This script submits an adjustment, then polls the read endpoint with exponential
backoff until the observed quantity matches the expected quantity. If the poll
budget runs out first, it flags the adjustment for an operator instead of ever
calling /v3/inventory/adjustments again. Re-submitting a write to "fix" a stale
read risks double-applying a relative delta or masking a real failure downstream.

Guide: https://www.allanninal.dev/bigcommerce/inventory-read-after-write-lag/
"""
import os
import time
import logging

import requests

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

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"
MAX_ATTEMPTS = int(os.environ.get("MAX_ATTEMPTS", "6"))
BASE_DELAY_S = float(os.environ.get("BASE_DELAY_S", "1.0"))
MAX_DELAY_S = float(os.environ.get("MAX_DELAY_S", "60.0"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

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


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


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 confirm_inventory_write(
    expected_quantity: int,
    observed_quantity,
    adjustment_id,
    attempt: int,
    max_attempts: int,
    base_delay_s: float = 1.0,
    max_delay_s: float = 60.0,
) -> dict:
    """Pure decision. No network, no side effects.

    Decide whether an inventory read confirms a prior write, and if not,
    whether to retry or flag.

    Returns {"status": "confirmed"|"retry"|"stale_flagged", "next_delay_s": float|None, "reason": str}

    if adjustment_id is None: status="stale_flagged", reason="missing action id, cannot confirm"
    elif observed_quantity == expected_quantity: status="confirmed"
    elif attempt >= max_attempts: status="stale_flagged", reason="poll budget exhausted"
    else: status="retry", next_delay_s=min(base_delay_s * (2 ** attempt), max_delay_s)
    """
    if adjustment_id is None:
        return {
            "status": "stale_flagged",
            "next_delay_s": None,
            "reason": "missing action id, cannot confirm",
        }
    if observed_quantity == expected_quantity:
        return {"status": "confirmed", "next_delay_s": None, "reason": "quantity matches"}
    if attempt >= max_attempts:
        return {
            "status": "stale_flagged",
            "next_delay_s": None,
            "reason": "poll budget exhausted",
        }
    delay = min(base_delay_s * (2 ** attempt), max_delay_s)
    return {"status": "retry", "next_delay_s": delay, "reason": "quantity not yet confirmed"}


def submit_adjustment(mode, reason, sku, location_id, quantity):
    """mode is 'absolute' or 'relative'."""
    body = {
        "reason": reason,
        "items": [{"sku": sku, "location_id": location_id, "quantity": quantity}],
    }
    return bc_put(f"/inventory/adjustments/{mode}", body)


def read_item(sku, location_id):
    data = bc_get("/inventory/items", {"location_ids": location_id, "skus": sku})
    rows = data.get("data") or []
    return rows[0] if rows else None


def confirm_write(sku, location_id, expected_quantity, adjustment_id):
    """Submit-independent confirmation loop. Only ever reads, never re-writes."""
    started = time.monotonic()
    attempt = 0
    observed = None
    while True:
        item = read_item(sku, location_id)
        observed = item.get("available_to_sell") if item else None
        decision = confirm_inventory_write(
            expected_quantity, observed, adjustment_id, attempt, MAX_ATTEMPTS,
            BASE_DELAY_S, MAX_DELAY_S,
        )
        if decision["status"] != "retry":
            elapsed_ms = int((time.monotonic() - started) * 1000)
            return decision, observed, attempt, elapsed_ms
        time.sleep(decision["next_delay_s"])
        attempt += 1


def run(sku, location_id, expected_quantity, mode="absolute", reason="stock recount"):
    log.info(
        "Submitting %s adjustment sku=%s location_id=%s expected_quantity=%s (%s)",
        mode, sku, location_id, expected_quantity, "dry run" if DRY_RUN else "writing",
    )

    if DRY_RUN:
        log.info("DRY_RUN=true, skipping the write and the confirmation poll.")
        return

    response = submit_adjustment(mode, reason, sku, location_id, expected_quantity)
    adjustment_id = (response.get("data") or {}).get("id")

    decision, observed, attempt, elapsed_ms = confirm_write(
        sku, location_id, expected_quantity, adjustment_id
    )

    if decision["status"] == "confirmed":
        log.info(
            "Confirmed sku=%s location_id=%s quantity=%s after %d attempt(s), %dms",
            sku, location_id, observed, attempt, elapsed_ms,
        )
        return

    record = {
        "adjustment_id": adjustment_id,
        "sku": sku,
        "location_id": location_id,
        "expected_quantity": expected_quantity,
        "last_observed_quantity": observed,
        "attempts": attempt,
        "elapsed_ms": elapsed_ms,
    }
    log.warning("STALE_FLAGGED %s reason=%s", record, decision["reason"])


if __name__ == "__main__":
    run(
        sku=os.environ.get("SKU", "example-sku"),
        location_id=int(os.environ.get("LOCATION_ID", "1")),
        expected_quantity=int(os.environ.get("EXPECTED_QUANTITY", "0")),
    )
confirm-inventory-write.js
/**
 * Confirm BigCommerce inventory adjustments instead of trusting the write's 200.
 *
 * BigCommerce's Inventory API (PUT /v3/inventory/adjustments/absolute or /relative)
 * processes writes asynchronously. The call returns 200 with an action id (data.id)
 * as soon as the request is accepted into the processing pipeline, not after the new
 * quantity is durably committed and propagated to the read path. BigCommerce's own
 * docs describe this as eventual consistency: "there may be a short delay before
 * data is updated after the endpoints are called." A relative adjustment can even
 * race against a still-in-flight absolute adjustment's pre-check stage and apply
 * the pre-adjustment value. A GET immediately after a write can therefore return
 * the pre-write quantity with no error or signal that it is stale.
 *
 * This script submits an adjustment, then polls the read endpoint with exponential
 * backoff until the observed quantity matches the expected quantity. If the poll
 * budget runs out first, it flags the adjustment for an operator instead of ever
 * calling /v3/inventory/adjustments again. Re-submitting a write to "fix" a stale
 * read risks double-applying a relative delta or masking a real failure downstream.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/inventory-read-after-write-lag/
 */
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 MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS || 6);
const BASE_DELAY_S = Number(process.env.BASE_DELAY_S || 1.0);
const MAX_DELAY_S = Number(process.env.MAX_DELAY_S || 60.0);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

/**
 * Pure decision. No network, no side effects.
 *
 * Decide whether an inventory read confirms a prior write, and if not,
 * whether to retry or flag.
 *
 * Returns { status: "confirmed"|"retry"|"stale_flagged", nextDelayS: number|null, reason: string }
 *
 * if adjustmentId is null/undefined: status="stale_flagged", reason="missing action id, cannot confirm"
 * else if observedQuantity === expectedQuantity: status="confirmed"
 * else if attempt >= maxAttempts: status="stale_flagged", reason="poll budget exhausted"
 * else: status="retry", nextDelayS=min(baseDelayS * (2 ** attempt), maxDelayS)
 */
export function confirmInventoryWrite(
  expectedQuantity,
  observedQuantity,
  adjustmentId,
  attempt,
  maxAttempts,
  baseDelayS = 1.0,
  maxDelayS = 60.0,
) {
  if (adjustmentId === null || adjustmentId === undefined) {
    return {
      status: "stale_flagged",
      nextDelayS: null,
      reason: "missing action id, cannot confirm",
    };
  }
  if (observedQuantity === expectedQuantity) {
    return { status: "confirmed", nextDelayS: null, reason: "quantity matches" };
  }
  if (attempt >= maxAttempts) {
    return { status: "stale_flagged", nextDelayS: null, reason: "poll budget exhausted" };
  }
  const delay = Math.min(baseDelayS * 2 ** attempt, maxDelayS);
  return { status: "retry", nextDelayS: delay, reason: "quantity not yet confirmed" };
}

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

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 submitAdjustment(mode, reason, sku, locationId, quantity) {
  // mode is "absolute" or "relative"
  const body = {
    reason,
    items: [{ sku, location_id: locationId, quantity }],
  };
  return bcPut(`/inventory/adjustments/${mode}`, body);
}

async function readItem(sku, locationId) {
  const data = await bcGet("/inventory/items", { location_ids: locationId, skus: sku });
  const rows = data.data || [];
  return rows.length ? rows[0] : null;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function confirmWrite(sku, locationId, expectedQuantity, adjustmentId) {
  const started = Date.now();
  let attempt = 0;
  let observed = null;
  while (true) {
    const item = await readItem(sku, locationId);
    observed = item ? item.available_to_sell : null;
    const decision = confirmInventoryWrite(
      expectedQuantity, observed, adjustmentId, attempt, MAX_ATTEMPTS,
      BASE_DELAY_S, MAX_DELAY_S,
    );
    if (decision.status !== "retry") {
      const elapsedMs = Date.now() - started;
      return { decision, observed, attempt, elapsedMs };
    }
    await sleep(decision.nextDelayS * 1000);
    attempt += 1;
  }
}

export async function run(
  sku = process.env.SKU || "example-sku",
  locationId = Number(process.env.LOCATION_ID || 1),
  expectedQuantity = Number(process.env.EXPECTED_QUANTITY || 0),
  mode = "absolute",
  reason = "stock recount",
) {
  console.log(
    `Submitting ${mode} adjustment sku=${sku} location_id=${locationId} ` +
    `expected_quantity=${expectedQuantity} (${DRY_RUN ? "dry run" : "writing"})`
  );

  if (DRY_RUN) {
    console.log("DRY_RUN=true, skipping the write and the confirmation poll.");
    return;
  }

  const response = await submitAdjustment(mode, reason, sku, locationId, expectedQuantity);
  const adjustmentId = response.data ? response.data.id : undefined;

  const { decision, observed, attempt, elapsedMs } = await confirmWrite(
    sku, locationId, expectedQuantity, adjustmentId
  );

  if (decision.status === "confirmed") {
    console.log(
      `Confirmed sku=${sku} location_id=${locationId} quantity=${observed} ` +
      `after ${attempt} attempt(s), ${elapsedMs}ms`
    );
    return;
  }

  const record = {
    adjustment_id: adjustmentId,
    sku,
    location_id: locationId,
    expected_quantity: expectedQuantity,
    last_observed_quantity: observed,
    attempts: attempt,
    elapsed_ms: elapsedMs,
  };
  console.warn("STALE_FLAGGED", record, "reason=", decision.reason);
}

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 whether the job retries, confirms, or escalates to an operator. Because confirm_inventory_write takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in numbers and checks the answer.

test_inventory_confirmation.py
from confirm_inventory_write import confirm_inventory_write


def test_confirmed_when_observed_matches_expected():
    result = confirm_inventory_write(50, 50, "adj_1", 0, 6)
    assert result["status"] == "confirmed"


def test_stale_flagged_when_adjustment_id_missing():
    result = confirm_inventory_write(50, 40, None, 0, 6)
    assert result["status"] == "stale_flagged"
    assert result["reason"] == "missing action id, cannot confirm"


def test_retry_when_not_matching_and_budget_remains():
    result = confirm_inventory_write(50, 40, "adj_1", 0, 6)
    assert result["status"] == "retry"
    assert result["next_delay_s"] == 1.0


def test_retry_delay_doubles_each_attempt():
    result = confirm_inventory_write(50, 40, "adj_1", 2, 6)
    assert result["next_delay_s"] == 4.0


def test_retry_delay_caps_at_max_delay():
    result = confirm_inventory_write(50, 40, "adj_1", 10, 20, base_delay_s=1.0, max_delay_s=60.0)
    assert result["next_delay_s"] == 60.0


def test_stale_flagged_when_budget_exhausted():
    result = confirm_inventory_write(50, 40, "adj_1", 6, 6)
    assert result["status"] == "stale_flagged"
    assert result["reason"] == "poll budget exhausted"
confirm-inventory-write.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { confirmInventoryWrite } from "./confirm-inventory-write.js";

test("confirmed when observed matches expected", () => {
  const result = confirmInventoryWrite(50, 50, "adj_1", 0, 6);
  assert.equal(result.status, "confirmed");
});

test("stale_flagged when adjustment id missing", () => {
  const result = confirmInventoryWrite(50, 40, null, 0, 6);
  assert.equal(result.status, "stale_flagged");
  assert.equal(result.reason, "missing action id, cannot confirm");
});

test("retry when not matching and budget remains", () => {
  const result = confirmInventoryWrite(50, 40, "adj_1", 0, 6);
  assert.equal(result.status, "retry");
  assert.equal(result.nextDelayS, 1.0);
});

test("retry delay doubles each attempt", () => {
  const result = confirmInventoryWrite(50, 40, "adj_1", 2, 6);
  assert.equal(result.nextDelayS, 4.0);
});

test("retry delay caps at max delay", () => {
  const result = confirmInventoryWrite(50, 40, "adj_1", 10, 20, 1.0, 60.0);
  assert.equal(result.nextDelayS, 60.0);
});

test("stale_flagged when budget exhausted", () => {
  const result = confirmInventoryWrite(50, 40, "adj_1", 6, 6);
  assert.equal(result.status, "stale_flagged");
  assert.equal(result.reason, "poll budget exhausted");
});

Case studies

Sync job under load

The nightly recount that flagged more items as traffic grew

A merchant ran a nightly stock recount that wrote an absolute adjustment for every changed SKU, then immediately read it back to log a before/after diff for the day's report. On quiet nights every read matched on the first try. On busy nights, with the store's own checkout traffic also hitting the inventory pipeline, a growing share of reads came back stale on attempt one.

Adding the poll-with-backoff loop fixed the report without touching the recount logic itself. Almost everything now confirms within two attempts. The rare item that still does not gets a structured stale_flagged record instead of a wrong number silently written into the report.

Racing adjustments

The double adjustment that looked like it undid itself

An integration issued an absolute adjustment to correct a count, then, moments later, a relative adjustment from an unrelated order fulfillment event touched the same SKU and location. Support first assumed the correction had silently failed, because a quick manual check showed a number that did not match either write cleanly.

Reading BigCommerce's own note on this race, that a relative adjustment can apply against a pre-check value from a still-in-flight absolute one, explained the mismatch. The fix was not to force a value back in. It was to confirm each write independently by its own adjustment id and expected quantity, and let the poll loop catch the one that genuinely needed a second look.

What good looks like

After this runs alongside every adjustment, a confirmed write is trusted the moment the read matches, usually within the first attempt or two. Anything that has not settled within the poll budget is never guessed at or silently re-written. It shows up as a clean, structured record for an operator, with the adjustment id, sku or variant id, location, expected and last observed quantity, attempts, and elapsed time all in one place.

FAQ

Why does a GET right after a BigCommerce inventory adjustment show the old quantity?

The Inventory API processes adjustments asynchronously. The PUT to /v3/inventory/adjustments returns 200 with an action id as soon as the request is accepted into the processing pipeline, not after the new quantity is durably committed and propagated to the read path. BigCommerce's own docs describe this as eventual consistency, with a short delay before data is updated after the endpoint is called.

Is it safe to just re-submit the adjustment if the read still looks stale?

No. This is a read-timing problem, not corrupt data, so re-issuing the same adjustment risks double-applying a relative delta or masking a real downstream failure. The safe response is to poll the read path with backoff and, if it still does not confirm within budget, flag the adjustment for an operator instead of writing again.

Can a relative adjustment race against an absolute adjustment that is still in flight?

Yes. BigCommerce warns that a relative adjustment can race against a still-in-flight absolute adjustment's pre-check stage, applying the pre-adjustment value instead of the value the absolute adjustment was about to set. That is one more reason to confirm a write by reading it back rather than assuming a 200 means the new quantity is already live.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: inventory adjustments, eventual consistency, and the race between relative and absolute adjustments. developer.bigcommerce.com inventory adjustments
  2. BigCommerce Developer Center: the REST Management adjustments reference. developer.bigcommerce.com adjustments
  3. BigCommerce Developer Center: Inventory overview. developer.bigcommerce.com inventory overview

On the solution:

  1. BigCommerce Developer Center: the Inventory REST Management reference, request and response fields, identity, and location_id. developer.bigcommerce.com inventory
  2. BigCommerce Developer Center: absolute versus relative adjustment endpoints. developer.bigcommerce.com adjustments
  3. BigCommerce Developer Center: Inventory and Location webhooks. developer.bigcommerce.com inventory and location webhooks

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 a stale read from causing a bad decision?

If this saved you from chasing a phantom inventory bug or caught a race you would have otherwise missed, 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