Reconciler Inventory
Storefront GraphQL variant inventory returns flaky values
One query says a variant has 12 units available to sell. The next query, seconds later, says 4. The Management API, the actual source of truth, says the real number is 4 the whole time. The Storefront GraphQL API's inventory fields sit behind caching layers and a default-location aggregation rule, and either one can make availableToSell disagree with reality without anything being broken in the data itself. Here is why the number flickers and a small reconciler that flags the real mismatches instead of guessing at a fix.
BigCommerce's GraphQL Storefront API serves inventory.aggregated.availableToSell through cached response layers, CDN edge caching plus storefront-side caching such as a Next.js data cache or an Apollo client cache, so a query can return a snapshot computed before a very recent stock adjustment has propagated. This is compounded by multi-location aggregation: aggregated stock reflects only the store's default location by default, so an adjustment at a non-default or newly enabled location can leave the Storefront API's aggregated figure permanently out of step with the Management API's true total. Run a small Python or Node.js script that pulls each variant's inventory_level from GET /v3/catalog/products/{product_id}/variants and diffs it against the same variant's availableToSell from the Storefront GraphQL API, re-polling any mismatch after 30 to 60 seconds. If the delta disappears, it was ordinary cache staleness. If it persists across at least two polls, log it as a flag, never a silent auto-adjustment. Full code, tests, and a dry run guard are below.
The problem in plain words
The Storefront GraphQL API is the read path most storefronts use to show stock, because it is fast and it is meant to be cached. That is also exactly why it can go stale. A query for site.product(entityId).variants.edges { node { inventory { aggregated { availableToSell } } } } can be served from a CDN edge cache, or from a storefront-side cache layer like a Next.js data cache or an Apollo client cache, that was populated before the most recent stock adjustment landed. Nothing is corrupted. The response is just a snapshot from a moment ago.
There is a second, sneakier cause layered on top. By default, the aggregated inventory figure the Storefront API returns only reflects the store's default location. If stock gets adjusted at a non-default location, or at a location that was only recently enabled for the catalog, the Management API's true total already includes it, but the Storefront API's availableToSell does not, and never will until the aggregation or channel assignment is fixed. That produces a mismatch that looks intermittent per variant, but for that specific misconfiguration it is not transient at all: it never converges, because the underlying rule, not the cache, is what is wrong.
Why it happens
The Storefront GraphQL API is optimized to be cached, and multi-location inventory has an aggregation rule most integrations do not expect. A few concrete ways this shows up:
- CDN edge caching in front of the GraphQL endpoint serves a response computed before a very recent adjustment has propagated, so the same query returns different numbers a few seconds apart.
- Storefront-side caching, a Next.js data cache, an Apollo client cache, or any other response cache the storefront layer keeps on top of the API, holds onto an old value even after the upstream cache has cleared.
- Aggregated stock (
inventory.aggregated.availableToSell) reflects only the store's default location by default. Adjusting stock at a non-default or newly enabled location changes the Management API's true total but not what GraphQL aggregates, so the two never converge until the location configuration itself is fixed. - Community reports of this bug describe it as intermittent and breaking storefront logic that assumes availableToSell is live, which is consistent with a caching layer rather than a data integrity problem. See the citations at the end for the exact support threads and GitHub issue.
The Management API's inventory_level is the source of truth. The Storefront GraphQL API's availableToSell is a read-path value that can lag behind it for two very different reasons, ordinary cache staleness that resolves itself, or a location-aggregation misconfiguration that never resolves on its own. So the safe pattern is not "trust whichever number you saw last." It is "diff the two per variant, and re-poll before deciding which kind of mismatch you are looking at." A mismatch that disappears on a re-poll a short delay later is transient. A mismatch that persists across at least two consecutive polls is a real misconfiguration worth flagging, and the only one worth ever writing back for.
The fix, as a flow
We do not touch the storefront cache or the CDN. We add a job that reads both sides for the same variant, computes the delta, and classifies it, and only escalates to a corrective write in the one narrow case where the delta is confirmed stable and the cause is a known aggregation misconfiguration.
Build it step by step
Get a store hash, a Management token, and a Storefront GraphQL token
Create an API account in your BigCommerce control panel under Settings, API, for the REST Management calls, sent as the X-Auth-Token header. The Storefront GraphQL API needs its own storefront token, generated from a storefront API account or your channel settings, and is queried against https://store-{store_hash}.mybigcommerce.com/graphql. Keep the store hash and both tokens in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export BIGCOMMERCE_STOREFRONT_TOKEN="..."
export MIN_STABLE_POLLS="2"
export POLL_DELAY_SECONDS="45"
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="..."
export BIGCOMMERCE_STOREFRONT_TOKEN="..."
export MIN_STABLE_POLLS="2"
export POLL_DELAY_SECONDS="45"
export DRY_RUN="true" // start safe, change to false to write
Talk to both APIs
REST Management calls go to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. GraphQL Storefront calls are a POST to https://store-{store_hash}.mybigcommerce.com/graphql with the storefront token in an Authorization: Bearer header. A small helper handles both and raises on a non-2xx response or a GraphQL errors array.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
STOREFRONT_TOKEN = os.environ["BIGCOMMERCE_STOREFRONT_TOKEN"]
REST_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
GRAPHQL_URL = f"https://store-{STORE_HASH}.mybigcommerce.com/graphql"
REST_HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
GRAPHQL_HEADERS = {
"Authorization": f"Bearer {STOREFRONT_TOKEN}",
"Content-Type": "application/json",
}
def rest_get(path, params=None):
r = requests.get(f"{REST_BASE}{path}", headers=REST_HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def graphql_query(query, variables=None):
r = requests.post(GRAPHQL_URL, headers=GRAPHQL_HEADERS,
json={"query": query, "variables": variables or {}}, timeout=30)
r.raise_for_status()
payload = r.json()
if payload.get("errors"):
raise RuntimeError(f"GraphQL errors: {payload['errors']}")
return payload["data"]
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const STOREFRONT_TOKEN = process.env.BIGCOMMERCE_STOREFRONT_TOKEN;
const REST_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const GRAPHQL_URL = `https://store-${STORE_HASH}.mybigcommerce.com/graphql`;
const REST_HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
const GRAPHQL_HEADERS = {
Authorization: `Bearer ${STOREFRONT_TOKEN}`,
"Content-Type": "application/json",
};
async function restGet(path, params = {}) {
const url = new URL(`${REST_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: REST_HEADERS });
if (!res.ok) throw new Error(`BigCommerce REST ${res.status}`);
return res.json();
}
async function graphqlQuery(query, variables = {}) {
const res = await fetch(GRAPHQL_URL, {
method: "POST",
headers: GRAPHQL_HEADERS,
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`BigCommerce GraphQL ${res.status}`);
const payload = await res.json();
if (payload.errors) throw new Error(`GraphQL errors: ${JSON.stringify(payload.errors)}`);
return payload.data;
}
Pull the Management API's true inventory_level
Call GET /v3/catalog/products/{product_id}/variants, paginated with meta.pagination, to get each variant's id, sku, and inventory_level. This is the number we treat as ground truth for the diff.
def product_variants(product_id):
page = 1
while True:
payload = rest_get(f"/catalog/products/{product_id}/variants", {"page": page, "limit": 50})
data = payload.get("data") or []
if not data:
return
for variant in data:
yield variant
pagination = payload.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", page):
return
page += 1
async function* productVariants(productId) {
let page = 1;
while (true) {
const payload = await restGet(`/catalog/products/${productId}/variants`, { page, limit: 50 });
const data = payload.data || [];
if (!data.length) return;
for (const variant of data) yield variant;
const pagination = payload.meta?.pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
Pull the Storefront GraphQL's availableToSell for the same variant
Query site.product(entityId).variants.edges { node { entityId, sku, inventory { aggregated { availableToSell, warningLevel }, isInStock } } } for the same product entity id. Match variants to the REST side by sku or entityId/id.
VARIANT_INVENTORY_QUERY = """
query VariantInventory($entityId: Int!) {
site {
product(entityId: $entityId) {
variants {
edges {
node {
entityId
sku
inventory {
aggregated { availableToSell, warningLevel }
isInStock
}
}
}
}
}
}
}
"""
def graphql_variant_inventory(product_entity_id):
data = graphql_query(VARIANT_INVENTORY_QUERY, {"entityId": product_entity_id})
edges = data["site"]["product"]["variants"]["edges"]
return {edge["node"]["sku"]: edge["node"]["inventory"]["aggregated"]["availableToSell"] for edge in edges}
const VARIANT_INVENTORY_QUERY = `
query VariantInventory($entityId: Int!) {
site {
product(entityId: $entityId) {
variants {
edges {
node {
entityId
sku
inventory {
aggregated { availableToSell, warningLevel }
isInStock
}
}
}
}
}
}
}
`;
async function graphqlVariantInventory(productEntityId) {
const data = await graphqlQuery(VARIANT_INVENTORY_QUERY, { entityId: productEntityId });
const edges = data.site.product.variants.edges;
const bySku = {};
for (const edge of edges) {
bySku[edge.node.sku] = edge.node.inventory.aggregated.availableToSell;
}
return bySku;
}
Decide, with one pure function
Keep the decision in its own function that takes the GraphQL availableToSell, the REST inventory_level, the variant's warning_level, and how many consecutive polls have shown the same delta. A zero delta is in_sync. A nonzero delta seen for the first time is transient, worth a re-poll. A nonzero delta that has now held for at least min_stable_polls consecutive checks is flag, the point where it stops being ordinary cache lag and becomes something a human, or a very narrow guarded fix, needs to look at.
def diff_variant_stock(graphql_available_to_sell, rest_inventory_level, warning_level, poll_count_matching, min_stable_polls=2):
if graphql_available_to_sell is None:
delta = None
else:
delta = graphql_available_to_sell - rest_inventory_level
if delta == 0:
return {"status": "in_sync", "delta": 0}
safe_delta = delta if delta is not None else rest_inventory_level
if poll_count_matching >= min_stable_polls:
return {"status": "flag", "delta": safe_delta}
return {"status": "transient", "delta": safe_delta}
function diffVariantStock(graphqlAvailableToSell, restInventoryLevel, warningLevel, pollCountMatching, minStablePolls = 2) {
let delta;
if (graphqlAvailableToSell === null || graphqlAvailableToSell === undefined) {
delta = null;
} else {
delta = graphqlAvailableToSell - restInventoryLevel;
}
if (delta === 0) return { status: "in_sync", delta: 0 };
const safeDelta = delta !== null ? delta : restInventoryLevel;
if (pollCountMatching >= minStablePolls) return { status: "flag", delta: safeDelta };
return { status: "transient", delta: safeDelta };
}
Wire it together with a dry run guard and a re-poll loop
The loop diffs every variant once, logs anything nonzero, waits POLL_DELAY_SECONDS, and diffs again. A delta that disappears on the second look was transient cache staleness and needs no further action. A delta that is still there is logged as a flag with the variant id, sku, both numbers, and the delta, for a human to check the location assignment in the BigCommerce admin. The only write this script ever makes, and only with DRY_RUN=false, is a targeted PUT /v3/catalog/products/{product_id}/variants/{variant_id} that sets inventory_level to the confirmed Management API truth, never to a value inferred from GraphQL, and only for a flagged, stable mismatch.
Always start with DRY_RUN=true. Never write an adjustment based on a single poll, and never derive the corrected value from the GraphQL side. The Management API's own inventory_level is the only number this script is ever allowed to write back, and only once a mismatch has held across at least two consecutive polls.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever proposes a corrective write for a mismatch that has been confirmed stable across repeated polls, never for a delta seen once.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Reconcile flaky BigCommerce Storefront GraphQL variant inventory.
The Storefront GraphQL API serves inventory.aggregated.availableToSell through
cached response layers, CDN edge caching plus storefront-side caching such as a
Next.js data cache or an Apollo client cache, so a query can return a snapshot
computed before a very recent stock adjustment has propagated. This is
compounded by multi-location aggregation: aggregated stock reflects only the
store's default location by default, so an adjustment at a non-default or
newly enabled location can leave the Storefront API's aggregated figure
permanently out of step with the Management API's true total. This job pulls
each variant's true inventory_level from the REST Management API, pulls the
same variant's availableToSell from the Storefront GraphQL API, and diffs
them. A nonzero delta is re-polled after a short delay. A delta that
disappears was ordinary cache staleness. A delta that survives multiple polls
is logged as a flag for manual review, and only in DRY_RUN=false mode is the
variant's own inventory_level corrected to match the confirmed Management API
truth, never a value inferred from GraphQL. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/storefront-graphql-flaky-inventory/
"""
import os
import time
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_variant_inventory")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
STOREFRONT_TOKEN = os.environ["BIGCOMMERCE_STOREFRONT_TOKEN"]
REST_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
GRAPHQL_URL = f"https://store-{STORE_HASH}.mybigcommerce.com/graphql"
MIN_STABLE_POLLS = int(os.environ.get("MIN_STABLE_POLLS", "2"))
POLL_DELAY_SECONDS = int(os.environ.get("POLL_DELAY_SECONDS", "45"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REST_HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
GRAPHQL_HEADERS = {
"Authorization": f"Bearer {STOREFRONT_TOKEN}",
"Content-Type": "application/json",
}
VARIANT_INVENTORY_QUERY = """
query VariantInventory($entityId: Int!) {
site {
product(entityId: $entityId) {
variants {
edges {
node {
entityId
sku
inventory {
aggregated { availableToSell, warningLevel }
isInStock
}
}
}
}
}
}
}
"""
def rest_get(path, params=None):
r = requests.get(f"{REST_BASE}{path}", headers=REST_HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def rest_put(path, body):
r = requests.put(f"{REST_BASE}{path}", headers=REST_HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def graphql_query(query, variables=None):
r = requests.post(GRAPHQL_URL, headers=GRAPHQL_HEADERS,
json={"query": query, "variables": variables or {}}, timeout=30)
r.raise_for_status()
payload = r.json()
if payload.get("errors"):
raise RuntimeError(f"GraphQL errors: {payload['errors']}")
return payload["data"]
def diff_variant_stock(
graphql_available_to_sell,
rest_inventory_level: int,
warning_level: int,
poll_count_matching: int,
min_stable_polls: int = 2,
) -> dict:
"""Pure decision logic. No I/O, no side effects.
Given the Storefront GraphQL's reported availableToSell for a variant,
the Management API's authoritative inventory_level, and how many
consecutive polls have shown the same delta, decide whether this is a
transient cache staleness event, a persistent oversell-risk mismatch to
flag, or in sync.
Returns {"status": "in_sync"|"transient"|"flag", "delta": int}.
"""
if graphql_available_to_sell is None:
delta = None
else:
delta = graphql_available_to_sell - rest_inventory_level
if delta == 0:
return {"status": "in_sync", "delta": 0}
safe_delta = delta if delta is not None else rest_inventory_level
if poll_count_matching >= min_stable_polls:
return {"status": "flag", "delta": safe_delta}
return {"status": "transient", "delta": safe_delta}
def product_variants(product_id):
page = 1
while True:
payload = rest_get(f"/catalog/products/{product_id}/variants", {"page": page, "limit": 50})
data = payload.get("data") or []
if not data:
return
for variant in data:
yield variant
pagination = payload.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", page):
return
page += 1
def graphql_variant_inventory(product_entity_id):
data = graphql_query(VARIANT_INVENTORY_QUERY, {"entityId": product_entity_id})
edges = data["site"]["product"]["variants"]["edges"]
return {edge["node"]["sku"]: edge["node"]["inventory"]["aggregated"]["availableToSell"] for edge in edges}
def correct_variant_inventory(product_id, variant_id, true_inventory_level):
return rest_put(
f"/catalog/products/{product_id}/variants/{variant_id}",
{"inventory_level": true_inventory_level},
)
def check_product(product_id):
"""Diff every variant of one product once. Returns a list of per-sku results."""
variants = list(product_variants(product_id))
graphql_by_sku = graphql_variant_inventory(product_id)
results = []
for variant in variants:
sku = variant.get("sku")
results.append({
"variant_id": variant["id"],
"sku": sku,
"rest_inventory_level": variant.get("inventory_level", 0),
"warning_level": variant.get("inventory_warning_level", 0),
"graphql_available_to_sell": graphql_by_sku.get(sku),
})
return results
def run():
product_ids = [int(pid) for pid in os.environ.get("PRODUCT_IDS", "").split(",") if pid.strip()]
if not product_ids:
log.warning("No PRODUCT_IDS configured. Set a comma separated list of product ids to check.")
return
poll_counts = {}
flagged = 0
in_sync = 0
for product_id in product_ids:
first_pass = check_product(product_id)
for row in first_pass:
key = (product_id, row["variant_id"])
decision = diff_variant_stock(
row["graphql_available_to_sell"], row["rest_inventory_level"],
row["warning_level"], poll_counts.get(key, 0), MIN_STABLE_POLLS,
)
if decision["status"] == "in_sync":
in_sync += 1
continue
log.info(
"product_id=%s variant_id=%s sku=%s graphql_available_to_sell=%s "
"rest_inventory_level=%s delta=%s status=%s (poll 1)",
product_id, row["variant_id"], row["sku"], row["graphql_available_to_sell"],
row["rest_inventory_level"], decision["delta"], decision["status"],
)
poll_counts[key] = 1
if not poll_counts:
continue
time.sleep(POLL_DELAY_SECONDS)
second_pass = check_product(product_id)
for row in second_pass:
key = (product_id, row["variant_id"])
if key not in poll_counts:
continue
decision = diff_variant_stock(
row["graphql_available_to_sell"], row["rest_inventory_level"],
row["warning_level"], poll_counts[key], MIN_STABLE_POLLS,
)
if decision["status"] == "in_sync":
log.info(
"product_id=%s variant_id=%s sku=%s converged after re-poll, transient cache staleness",
product_id, row["variant_id"], row["sku"],
)
in_sync += 1
continue
poll_counts[key] += 1
decision = diff_variant_stock(
row["graphql_available_to_sell"], row["rest_inventory_level"],
row["warning_level"], poll_counts[key], MIN_STABLE_POLLS,
)
if decision["status"] == "flag":
log.warning(
"FLAG product_id=%s variant_id=%s sku=%s graphql_available_to_sell=%s "
"rest_inventory_level=%s delta=%s (stable across %d polls)",
product_id, row["variant_id"], row["sku"], row["graphql_available_to_sell"],
row["rest_inventory_level"], decision["delta"], poll_counts[key],
)
flagged += 1
if not DRY_RUN:
correct_variant_inventory(product_id, row["variant_id"], row["rest_inventory_level"])
log.info(
"Corrected variant_id=%s inventory_level to confirmed Management API truth: %s",
row["variant_id"], row["rest_inventory_level"],
)
else:
log.info(
"product_id=%s variant_id=%s sku=%s still transient after re-poll, will re-check next run",
product_id, row["variant_id"], row["sku"],
)
log.info(
"Done. %d variant(s) in sync, %d variant(s) flagged%s.",
in_sync, flagged, " (dry run, no writes made)" if DRY_RUN and flagged else "",
)
if __name__ == "__main__":
run()
/**
* Reconcile flaky BigCommerce Storefront GraphQL variant inventory.
*
* The Storefront GraphQL API serves inventory.aggregated.availableToSell through
* cached response layers, CDN edge caching plus storefront-side caching such as a
* Next.js data cache or an Apollo client cache, so a query can return a snapshot
* computed before a very recent stock adjustment has propagated. This is
* compounded by multi-location aggregation: aggregated stock reflects only the
* store's default location by default, so an adjustment at a non-default or
* newly enabled location can leave the Storefront API's aggregated figure
* permanently out of step with the Management API's true total. This job pulls
* each variant's true inventory_level from the REST Management API, pulls the
* same variant's availableToSell from the Storefront GraphQL API, and diffs
* them. A nonzero delta is re-polled after a short delay. A delta that
* disappears was ordinary cache staleness. A delta that survives multiple polls
* is logged as a flag for manual review, and only in DRY_RUN=false mode is the
* variant's own inventory_level corrected to match the confirmed Management API
* truth, never a value inferred from GraphQL. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/storefront-graphql-flaky-inventory/
*/
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 STOREFRONT_TOKEN = process.env.BIGCOMMERCE_STOREFRONT_TOKEN || "sf_dummy";
const REST_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const GRAPHQL_URL = `https://store-${STORE_HASH}.mybigcommerce.com/graphql`;
const MIN_STABLE_POLLS = Number(process.env.MIN_STABLE_POLLS || 2);
const POLL_DELAY_SECONDS = Number(process.env.POLL_DELAY_SECONDS || 45);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REST_HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
const GRAPHQL_HEADERS = {
Authorization: `Bearer ${STOREFRONT_TOKEN}`,
"Content-Type": "application/json",
};
const VARIANT_INVENTORY_QUERY = `
query VariantInventory($entityId: Int!) {
site {
product(entityId: $entityId) {
variants {
edges {
node {
entityId
sku
inventory {
aggregated { availableToSell, warningLevel }
isInStock
}
}
}
}
}
}
}
`;
/**
* Pure decision logic. No I/O, no side effects.
*
* Given the Storefront GraphQL's reported availableToSell for a variant,
* the Management API's authoritative inventory_level, and how many
* consecutive polls have shown the same delta, decide whether this is a
* transient cache staleness event, a persistent oversell-risk mismatch to
* flag, or in sync.
*
* Returns {"status": "in_sync"|"transient"|"flag", "delta": int}.
*/
export function diffVariantStock(graphqlAvailableToSell, restInventoryLevel, warningLevel, pollCountMatching, minStablePolls = 2) {
let delta;
if (graphqlAvailableToSell === null || graphqlAvailableToSell === undefined) {
delta = null;
} else {
delta = graphqlAvailableToSell - restInventoryLevel;
}
if (delta === 0) return { status: "in_sync", delta: 0 };
const safeDelta = delta !== null ? delta : restInventoryLevel;
if (pollCountMatching >= minStablePolls) return { status: "flag", delta: safeDelta };
return { status: "transient", delta: safeDelta };
}
async function restGet(path, params = {}) {
const url = new URL(`${REST_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: REST_HEADERS });
if (!res.ok) throw new Error(`BigCommerce REST ${res.status}`);
return res.json();
}
async function restPut(path, body) {
const res = await fetch(`${REST_BASE}${path}`, {
method: "PUT",
headers: REST_HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce REST ${res.status}`);
return res.json();
}
async function graphqlQuery(query, variables = {}) {
const res = await fetch(GRAPHQL_URL, {
method: "POST",
headers: GRAPHQL_HEADERS,
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`BigCommerce GraphQL ${res.status}`);
const payload = await res.json();
if (payload.errors) throw new Error(`GraphQL errors: ${JSON.stringify(payload.errors)}`);
return payload.data;
}
async function* productVariants(productId) {
let page = 1;
while (true) {
const payload = await restGet(`/catalog/products/${productId}/variants`, { page, limit: 50 });
const data = payload.data || [];
if (!data.length) return;
for (const variant of data) yield variant;
const pagination = payload.meta?.pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
async function graphqlVariantInventory(productEntityId) {
const data = await graphqlQuery(VARIANT_INVENTORY_QUERY, { entityId: productEntityId });
const edges = data.site.product.variants.edges;
const bySku = {};
for (const edge of edges) {
bySku[edge.node.sku] = edge.node.inventory.aggregated.availableToSell;
}
return bySku;
}
async function correctVariantInventory(productId, variantId, trueInventoryLevel) {
return restPut(`/catalog/products/${productId}/variants/${variantId}`, { inventory_level: trueInventoryLevel });
}
async function checkProduct(productId) {
const variants = [];
for await (const variant of productVariants(productId)) variants.push(variant);
const graphqlBySku = await graphqlVariantInventory(productId);
return variants.map((variant) => ({
variantId: variant.id,
sku: variant.sku,
restInventoryLevel: variant.inventory_level ?? 0,
warningLevel: variant.inventory_warning_level ?? 0,
graphqlAvailableToSell: graphqlBySku[variant.sku] ?? null,
}));
}
function sleep(seconds) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
export async function run() {
const productIds = (process.env.PRODUCT_IDS || "")
.split(",")
.map((id) => id.trim())
.filter(Boolean)
.map(Number);
if (!productIds.length) {
console.warn("No PRODUCT_IDS configured. Set a comma separated list of product ids to check.");
return;
}
const pollCounts = new Map();
let flagged = 0;
let inSync = 0;
for (const productId of productIds) {
const firstPass = await checkProduct(productId);
for (const row of firstPass) {
const key = `${productId}:${row.variantId}`;
const decision = diffVariantStock(row.graphqlAvailableToSell, row.restInventoryLevel, row.warningLevel, pollCounts.get(key) || 0, MIN_STABLE_POLLS);
if (decision.status === "in_sync") {
inSync += 1;
continue;
}
console.log(
`product_id=${productId} variant_id=${row.variantId} sku=${row.sku} graphql_available_to_sell=${row.graphqlAvailableToSell} ` +
`rest_inventory_level=${row.restInventoryLevel} delta=${decision.delta} status=${decision.status} (poll 1)`
);
pollCounts.set(key, 1);
}
if (!pollCounts.size) continue;
await sleep(POLL_DELAY_SECONDS);
const secondPass = await checkProduct(productId);
for (const row of secondPass) {
const key = `${productId}:${row.variantId}`;
if (!pollCounts.has(key)) continue;
let decision = diffVariantStock(row.graphqlAvailableToSell, row.restInventoryLevel, row.warningLevel, pollCounts.get(key), MIN_STABLE_POLLS);
if (decision.status === "in_sync") {
console.log(`product_id=${productId} variant_id=${row.variantId} sku=${row.sku} converged after re-poll, transient cache staleness`);
inSync += 1;
continue;
}
pollCounts.set(key, pollCounts.get(key) + 1);
decision = diffVariantStock(row.graphqlAvailableToSell, row.restInventoryLevel, row.warningLevel, pollCounts.get(key), MIN_STABLE_POLLS);
if (decision.status === "flag") {
console.warn(
`FLAG product_id=${productId} variant_id=${row.variantId} sku=${row.sku} graphql_available_to_sell=${row.graphqlAvailableToSell} ` +
`rest_inventory_level=${row.restInventoryLevel} delta=${decision.delta} (stable across ${pollCounts.get(key)} polls)`
);
flagged += 1;
if (!DRY_RUN) {
await correctVariantInventory(productId, row.variantId, row.restInventoryLevel);
console.log(`Corrected variant_id=${row.variantId} inventory_level to confirmed Management API truth: ${row.restInventoryLevel}`);
}
} else {
console.log(`product_id=${productId} variant_id=${row.variantId} sku=${row.sku} still transient after re-poll, will re-check next run`);
}
}
}
console.log(`Done. ${inSync} variant(s) in sync, ${flagged} variant(s) flagged${DRY_RUN && flagged ? " (dry run, no writes made)" : ""}.`);
}
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 a mismatch gets ignored, watched, or flagged (and, once confirmed, corrected). Because diff_variant_stock 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.
from reconcile_variant_inventory import diff_variant_stock
def test_in_sync_when_values_match():
assert diff_variant_stock(10, 10, 5, 0) == {"status": "in_sync", "delta": 0}
def test_transient_on_first_mismatch():
result = diff_variant_stock(12, 4, 5, 0)
assert result == {"status": "transient", "delta": 8}
def test_transient_below_min_stable_polls():
result = diff_variant_stock(12, 4, 5, 1, min_stable_polls=2)
assert result == {"status": "transient", "delta": 8}
def test_flag_once_min_stable_polls_reached():
result = diff_variant_stock(12, 4, 5, 2, min_stable_polls=2)
assert result == {"status": "flag", "delta": 8}
def test_flag_when_graphql_reports_none_and_stable():
result = diff_variant_stock(None, 7, 5, 2, min_stable_polls=2)
assert result == {"status": "flag", "delta": 7}
def test_negative_delta_when_graphql_overreports():
result = diff_variant_stock(2, 9, 5, 2, min_stable_polls=2)
assert result == {"status": "flag", "delta": -7}
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffVariantStock } from "./reconcile-variant-inventory.js";
test("in_sync when values match", () => {
assert.deepEqual(diffVariantStock(10, 10, 5, 0), { status: "in_sync", delta: 0 });
});
test("transient on first mismatch", () => {
assert.deepEqual(diffVariantStock(12, 4, 5, 0), { status: "transient", delta: 8 });
});
test("transient below min stable polls", () => {
assert.deepEqual(diffVariantStock(12, 4, 5, 1, 2), { status: "transient", delta: 8 });
});
test("flag once min stable polls reached", () => {
assert.deepEqual(diffVariantStock(12, 4, 5, 2, 2), { status: "flag", delta: 8 });
});
test("flag when graphql reports null and stable", () => {
assert.deepEqual(diffVariantStock(null, 7, 5, 2, 2), { status: "flag", delta: 7 });
});
test("negative delta when graphql overreports", () => {
assert.deepEqual(diffVariantStock(2, 9, 5, 2, 2), { status: "flag", delta: -7 });
});
Case studies
The headless storefront where a sellout kept un-selling itself
A Next.js storefront in front of BigCommerce showed a variant as in stock seconds after the admin had already recorded it as sold out, because the storefront's own data cache and the CDN in front of GraphQL were both still serving the previous response. Support staff assumed the catalog sync was broken and started manually forcing product updates to bust the cache.
Running the reconciler on the flagged SKUs showed every mismatch converging on the second poll, well inside the 30 to 60 second window. Nothing was actually broken. The team set a shorter cache TTL for low-stock variants and stopped forcing manual updates, because the diff data showed the drift was always transient.
The store that added a second warehouse and never saw it in availableToSell
A store enabled a second fulfillment location and started adjusting stock there for a subset of products. The BigCommerce admin's Management API reflected the new totals correctly, but the storefront kept showing the old, lower numbers indefinitely, because aggregated availableToSell only reflected the original default location.
The reconciler flagged those exact SKUs as persistent, not transient, since the delta never converged across repeated polls. That pointed the team straight at the real cause, the new location was never included in aggregation, instead of chasing a caching problem that did not exist for those variants.
After this runs on a schedule, every mismatch between the Storefront GraphQL API and the Management API gets sorted correctly: ordinary cache staleness converges on its own and needs no attention, while a persistent, stable mismatch gets flagged with the exact variant, sku, both numbers, and the delta, so a human can check the location assignment instead of guessing. The only automatic write this script ever makes is a narrow, guarded correction of a variant's own inventory_level to the confirmed Management API truth, never a value invented from GraphQL, and never before the delta has held across at least two consecutive polls.
FAQ
Why does the Storefront GraphQL API show a different stock number than the admin?
The Storefront GraphQL API's inventory.aggregated.availableToSell is served through cached response layers, CDN edge caching plus storefront-side caching such as a Next.js data cache or an Apollo client cache, so a query can return a snapshot computed before a very recent stock adjustment has propagated. It can also diverge because aggregated stock only reflects the store's default location by default, so an adjustment at a non-default or newly enabled location will not show up in availableToSell even though the Management API's true total already includes it.
Is it safe to auto-correct inventory_level whenever GraphQL and the Management API disagree?
No. The Management API is the source of truth and the mismatch is usually a read-path caching artifact, not corrupted data, so writing an adjustment based on a single diff risks masking a real oversell or double adjusting stock. Only flag and log the mismatch, re-poll after a short delay to see whether it converges, and only ever write inventory_level back for a confirmed non-default-location aggregation misconfiguration, guarded by DRY_RUN and only once the delta has persisted across at least two consecutive polls.
Why does re-polling after 30 to 60 seconds matter?
A transient cache staleness event resolves itself once the CDN edge cache or the storefront-side cache expires and revalidates, typically within a short window. Re-polling after 30 to 60 seconds distinguishes that ordinary staleness, where the delta disappears, from a persistent misconfiguration such as a non-default location left out of aggregation, where the delta never converges and needs a human or a targeted correction instead of another wait.
Related field notes
Citations
On the problem:
- BigCommerce Support: variant inventory bug in Storefront API, flaky response causing breaking issues. support.bigcommerce.com variant inventory bug in Storefront API
- BigCommerce Support Community: is there any way to get variant stock level? support.bigcommerce.com variant stock level
- GitHub issue on the storefront API examples repo, about stock and inventory data not being available in GraphQL. github.com storefront API examples issue 1
On the solution:
- BigCommerce Developer Center: query inventory with the GraphQL Storefront API. developer.bigcommerce.com inventory queries
- BigCommerce Developer Center: get inventory, Storefront GraphQL settings and behavior. developer.bigcommerce.com inventory settings
- BigCommerce Docs: variants in the GraphQL Storefront API. docs.bigcommerce.com GraphQL Storefront variants
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.
Did this stop your flaky stock counts?
If this helped you tell apart ordinary cache lag from a real inventory misconfiguration, 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