Reconciler Catalog / Products
BigCommerce SKUs endpoint truncates at 50 records without paginating
A product has 80 variants. A script calls the SKUs endpoint once, gets a tidy array back, and moves on. That array has exactly 50 rows in it. Nothing in the call itself says anything is missing. BigCommerce's catalog SKU and variant sub-resource endpoints are paginated collections where the limit query parameter silently defaults to 50 per page when you leave it out, capped at 250, and the API never auto-paginates or warns you. Here is why that gap opens up and a small reconciler that finds every product where it happened and re-fetches the complete list.
Both GET /v2/products/{id}/skus and its V3 successor GET /v3/catalog/products/{id}/variants are paginated collection endpoints. When you omit the limit query parameter, BigCommerce silently applies a default of 50 records per page, with a documented maximum of 250. If your code calls the endpoint once, without passing limit/page and without reading meta.pagination.total_pages, you get back exactly the first 50 SKUs or variants for any product that has more, and the response never flags that anything was cut off. This is a well known integration pitfall documented in BigCommerce's own SDK issue trackers, not a platform bug. The fix is to always paginate fully: request limit=250, loop while page < meta.pagination.total_pages, and treat a single-page call that returned exactly 50 records while meta.pagination.total is greater than 50 as the truncation signature. Full code, tests, and a reconciliation report are below.
The problem in plain words
The V2 endpoint GET /v2/products/{id}/skus, and the V3 endpoint that replaces it for most new integrations, GET /v3/catalog/products/{id}/variants, both return a collection, not a single object. Like every other BigCommerce collection endpoint, they accept limit and page query parameters. What is easy to miss is what happens when you do not pass them: BigCommerce does not return everything, and it does not error. It quietly applies its own default page size of 50 and hands back exactly that many rows, even if the product actually has 80, 200, or 500 SKUs.
A product with 30 variants looks completely fine under this pattern, because 30 is under the default page size and the single call happens to return all of them. The bug only shows up on products that cross the 50-record line, which is exactly the kind of thing that slips past manual testing on a handful of sample SKUs and then surfaces in production against a real catalog with large variant matrices. Nothing about the shape of the response signals the problem. It is still a data array, still valid JSON, still exactly what the calling code expected to see, just short.
Why it happens
This is a documented, well known integration pitfall, not a platform defect. A few concrete ways it reaches production code:
- Code written and tested against a handful of sample products, none of which happen to cross the 50-SKU line, so the bug never triggers during development.
- A client that reads
datafrom the response and never looks atmeta.paginationat all, so there is no signal in the code path that would ever notice a mismatch between records fetched and records that exist. - Confusing the SKU/variant sub-resource with the parent product record.
GET /v3/catalog/products/{id}?include=variantscan behave differently from the dedicated variants collection endpoint, and teams sometimes trust whichever one they tested first without reconciling the two. - Legacy V2 client libraries and generated SDKs that expose a single "get SKUs for product" call with no obvious pagination affordance, which is exactly the shape of bug reported against the BigCommerce PHP and Python API client libraries.
The endpoint is not lying or broken. It is doing precisely what a paginated collection endpoint is supposed to do: return one page, and describe the rest of the pages in meta.pagination, for the caller to act on. See the citations at the end for the exact SDK issues and API docs.
A single unpaginated call is never proof that you have every SKU or variant for a product. The proof is meta.pagination.total, or equivalently meta.pagination.total_pages, compared against how many records you actually retrieved across every page you fetched. The reconciler's job is not to guess whether a product was affected. It is to read that field on every call and treat records_fetched == 50 and meta_pagination_total > 50, on a call made without an explicit limit, as the unambiguous truncation signature, then re-fetch that product's full list before anything downstream trusts it.
The fix, as a flow
We never mutate a SKU or variant record. The reconciler pages through the product catalog, calls the variants (and, for cross-checking, the legacy SKUs) endpoint for each product, and compares what a naive single-page call would have returned against the true total from meta.pagination. Anything truncated gets a corrected, fully-paginated fetch and a line in the report.
Build it step by step
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. Products (read-only) scope is enough for detection and the corrected re-fetch, since this reconciler is a read path. 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.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" # this reconciler only ever reports and re-fetches, never writes
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" // this reconciler only ever reports and re-fetches, never writes
Talk to the V3 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 raises on a non-2xx response, and returns both the data array and the meta object, since meta.pagination is what the whole detection depends on.
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()
body = r.json() if r.text else {}
return body.get("data", []), body.get("meta", {})
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();
const body = text ? JSON.parse(text) : {};
return { data: body.data || [], meta: body.meta || {} };
}
List every product, and probe each one's variants with a single unpaginated call
Page through GET /v3/catalog/products?limit=250&page=N, following meta.pagination.total_pages, to enumerate every product_id. For each product, make the exact call a naive integration would make, GET /v3/catalog/products/{product_id}/variants with no limit or page at all, and keep both the record count returned and meta.pagination.total from that response. That single probe call is what feeds the decision function.
def all_product_ids():
page = 1
while True:
products, meta = bc_get("/catalog/products", {"limit": 250, "page": page})
if not products:
return
for product in products:
yield product["id"]
pagination = meta.get("pagination", {})
if page >= pagination.get("total_pages", page):
return
page += 1
def probe_variants_unpaginated(product_id):
"""The exact call a naive integration makes: no limit, no page."""
records, meta = bc_get(f"/catalog/products/{product_id}/variants")
total = meta.get("pagination", {}).get("total", len(records))
return len(records), total
async function* allProductIds() {
let page = 1;
while (true) {
const { data: products, meta } = await bcGet("/catalog/products", { limit: 250, page });
if (!products.length) return;
for (const product of products) yield product.id;
const pagination = meta.pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
async function probeVariantsUnpaginated(productId) {
// The exact call a naive integration makes: no limit, no page.
const { data: records, meta } = await bcGet(`/catalog/products/${productId}/variants`);
const total = (meta.pagination || {}).total ?? records.length;
return { recordsFetched: records.length, total };
}
Decide, with one pure function
Keep the truncation decision in its own function that takes only plain values, how many records a call actually returned, whether a limit was explicitly requested, and what meta.pagination.total reported, and returns a plain boolean. If no limit was passed, BigCommerce applied its implicit default of 50, so the call is truncated exactly when it returned 50 records and the true total is greater than 50. If a limit was passed, truncation means the records actually fetched fall short of the smaller of the requested limit and the true total.
from typing import Optional
IMPLICIT_DEFAULT_LIMIT = 50
def is_truncated(
records_fetched: int, page_limit_requested: Optional[int], meta_pagination_total: int
) -> bool:
if page_limit_requested is None:
return records_fetched == IMPLICIT_DEFAULT_LIMIT and meta_pagination_total > IMPLICIT_DEFAULT_LIMIT
expected = min(page_limit_requested, meta_pagination_total)
return records_fetched < expected
const IMPLICIT_DEFAULT_LIMIT = 50;
export function isTruncated(recordsFetched, pageLimitRequested, metaPaginationTotal) {
if (pageLimitRequested === null || pageLimitRequested === undefined) {
return recordsFetched === IMPLICIT_DEFAULT_LIMIT && metaPaginationTotal > IMPLICIT_DEFAULT_LIMIT;
}
const expected = Math.min(pageLimitRequested, metaPaginationTotal);
return recordsFetched < expected;
}
Re-fetch the complete list for anything flagged
When is_truncated is true for a product_id, do not trust the 50 records already in hand. Re-run the fetch fully paginated, limit=250, looping while page < meta.pagination.total_pages, and use that complete list as the corrected record set. This is the same shape of call whether you are re-populating a local mirror table or just producing the report a human will read.
def fetch_all_variants(product_id):
"""Fully paginated. Always returns the complete list, never truncated."""
all_records = []
page = 1
while True:
records, meta = bc_get(f"/catalog/products/{product_id}/variants", {"limit": 250, "page": page})
all_records.extend(records)
pagination = meta.get("pagination", {})
if not records or page >= pagination.get("total_pages", page):
return all_records
page += 1
async function fetchAllVariants(productId) {
// Fully paginated. Always returns the complete list, never truncated.
const allRecords = [];
let page = 1;
while (true) {
const { data: records, meta } = await bcGet(`/catalog/products/${productId}/variants`, { limit: 250, page });
allRecords.push(...records);
const pagination = meta.pagination || {};
if (!records.length || page >= (pagination.total_pages || page)) return allRecords;
page += 1;
}
}
Wire it together with a dry run guard
The run loop enumerates every product, probes each one with the naive unpaginated call, and only pays the cost of a full paginated re-fetch for the products where is_truncated comes back true. It emits one report line per affected product_id with expected_total (from meta.pagination.total) versus records_fetched_without_pagination (capped at 50) versus the corrected count. If you also mirror SKUs into a downstream table, gate the actual write to that table behind DRY_RUN, exactly like every other reconciler here: log first, write only once you have reviewed the list.
There is nothing wrong with the SKU or variant data on BigCommerce's side to fix. The defect is entirely in the unpaginated call. Never delete or overwrite anything based on the 50-record fetch. The only thing this job does with a positive detection is re-fetch fully paginated and, if you keep a downstream mirror table, guard that specific re-sync with DRY_RUN=true until you have reviewed the corrected counts.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through the full product catalog, probes each product's variants exactly the way a naive integration would, flags any product where the truncation signature shows up, and re-fetches the complete list for every one it flags.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and re-fetch BigCommerce SKU / variant lists truncated at 50 records.
GET /v2/products/{id}/skus and its V3 successor GET /v3/catalog/products/{id}/
variants are paginated collection endpoints. When the limit query parameter is
omitted, BigCommerce silently defaults it to 50 per page, with a documented
maximum of 250. A client that calls the endpoint once, without limit/page and
without reading meta.pagination.total_pages, only ever sees the first 50 SKUs
or variants for any product that has more, and the response never signals
anything was cut off. This is a well known integration pitfall documented in
BigCommerce's own SDK issue trackers, not a platform bug. This job pages
through the full product catalog, probes each product's variants with the
exact unpaginated call a naive integration would make, flags every product_id
where records_fetched == 50 and meta.pagination.total > 50 (the truncation
signature), and re-fetches the complete, fully paginated list for each one it
flags. It never deletes or rewrites a SKU record; it only corrects the read.
Guide: https://www.allanninal.dev/bigcommerce/sku-endpoint-truncates-at-50/
"""
import os
import logging
from dataclasses import dataclass
from typing import Optional
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_truncated_skus")
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"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
IMPLICIT_DEFAULT_LIMIT = 50
PAGE_LIMIT = 250
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()
body = r.json() if r.text else {}
return body.get("data", []), body.get("meta", {})
def is_truncated(
records_fetched: int, page_limit_requested: Optional[int], meta_pagination_total: int
) -> bool:
"""Pure decision. No network, no side effects.
If page_limit_requested is None, BigCommerce applied the implicit default
of 50, so the call is truncated when records_fetched == 50 and the true
total is greater than 50. If page_limit_requested is not None, truncation
means records_fetched is less than the smaller of the requested limit and
the true total, after exhausting every page implied by that total.
"""
if page_limit_requested is None:
return records_fetched == IMPLICIT_DEFAULT_LIMIT and meta_pagination_total > IMPLICIT_DEFAULT_LIMIT
expected = min(page_limit_requested, meta_pagination_total)
return records_fetched < expected
@dataclass
class ReconciliationRow:
product_id: int
expected_total: int
records_fetched_without_pagination: int
records_fetched_after_repair: int
def all_product_ids():
page = 1
while True:
products, meta = bc_get("/catalog/products", {"limit": PAGE_LIMIT, "page": page})
if not products:
return
for product in products:
yield product["id"]
pagination = meta.get("pagination", {})
if page >= pagination.get("total_pages", page):
return
page += 1
def probe_variants_unpaginated(product_id):
"""The exact call a naive integration makes: no limit, no page."""
records, meta = bc_get(f"/catalog/products/{product_id}/variants")
total = meta.get("pagination", {}).get("total", len(records))
return len(records), total
def fetch_all_variants(product_id):
"""Fully paginated. Always returns the complete list, never truncated."""
all_records = []
page = 1
while True:
records, meta = bc_get(
f"/catalog/products/{product_id}/variants", {"limit": PAGE_LIMIT, "page": page}
)
all_records.extend(records)
pagination = meta.get("pagination", {})
if not records or page >= pagination.get("total_pages", page):
return all_records
page += 1
def run():
affected: list[ReconciliationRow] = []
scanned = 0
for product_id in all_product_ids():
scanned += 1
records_fetched, expected_total = probe_variants_unpaginated(product_id)
if not is_truncated(records_fetched, None, expected_total):
continue
log.warning(
"product_id=%s truncated: records_fetched_without_pagination=%s expected_total=%s",
product_id, records_fetched, expected_total,
)
corrected = fetch_all_variants(product_id)
affected.append(
ReconciliationRow(
product_id=product_id,
expected_total=expected_total,
records_fetched_without_pagination=records_fetched,
records_fetched_after_repair=len(corrected),
)
)
if not DRY_RUN:
# Re-sync only this product_id's mirrored SKU rows here, using `corrected`.
log.info("product_id=%s re-synced with %s records.", product_id, len(corrected))
else:
log.info(
"product_id=%s would re-sync %s records (DRY_RUN=true, no write performed).",
product_id, len(corrected),
)
log.info(
"Done. Scanned %d product(s). %d product(s) were truncated at the implicit 50-record default.",
scanned, len(affected),
)
for row in affected:
log.info(
"REPORT product_id=%s expected_total=%s records_fetched_without_pagination=%s "
"records_fetched_after_repair=%s",
row.product_id, row.expected_total,
row.records_fetched_without_pagination, row.records_fetched_after_repair,
)
if __name__ == "__main__":
run()
/**
* Find and re-fetch BigCommerce SKU / variant lists truncated at 50 records.
*
* GET /v2/products/{id}/skus and its V3 successor GET /v3/catalog/products/{id}/
* variants are paginated collection endpoints. When the limit query parameter is
* omitted, BigCommerce silently defaults it to 50 per page, with a documented
* maximum of 250. A client that calls the endpoint once, without limit/page and
* without reading meta.pagination.total_pages, only ever sees the first 50 SKUs
* or variants for any product that has more, and the response never signals
* anything was cut off. This is a well known integration pitfall documented in
* BigCommerce's own SDK issue trackers, not a platform bug. This job pages
* through the full product catalog, probes each product's variants with the
* exact unpaginated call a naive integration would make, flags every productId
* where recordsFetched === 50 and meta.pagination.total > 50 (the truncation
* signature), and re-fetches the complete, fully paginated list for each one it
* flags. It never deletes or rewrites a SKU record; it only corrects the read.
*
* Guide: https://www.allanninal.dev/bigcommerce/sku-endpoint-truncates-at-50/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const IMPLICIT_DEFAULT_LIMIT = 50;
const PAGE_LIMIT = 250;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* If pageLimitRequested is null/undefined, BigCommerce applied the implicit
* default of 50, so the call is truncated when recordsFetched === 50 and the
* true total is greater than 50. If pageLimitRequested is set, truncation
* means recordsFetched is less than the smaller of the requested limit and
* the true total, after exhausting every page implied by that total.
*/
export function isTruncated(recordsFetched, pageLimitRequested, metaPaginationTotal) {
if (pageLimitRequested === null || pageLimitRequested === undefined) {
return recordsFetched === IMPLICIT_DEFAULT_LIMIT && metaPaginationTotal > IMPLICIT_DEFAULT_LIMIT;
}
const expected = Math.min(pageLimitRequested, metaPaginationTotal);
return recordsFetched < expected;
}
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();
const body = text ? JSON.parse(text) : {};
return { data: body.data || [], meta: body.meta || {} };
}
async function* allProductIds() {
let page = 1;
while (true) {
const { data: products, meta } = await bcGet("/catalog/products", { limit: PAGE_LIMIT, page });
if (!products.length) return;
for (const product of products) yield product.id;
const pagination = meta.pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
async function probeVariantsUnpaginated(productId) {
// The exact call a naive integration makes: no limit, no page.
const { data: records, meta } = await bcGet(`/catalog/products/${productId}/variants`);
const total = (meta.pagination || {}).total ?? records.length;
return { recordsFetched: records.length, total };
}
async function fetchAllVariants(productId) {
// Fully paginated. Always returns the complete list, never truncated.
const allRecords = [];
let page = 1;
while (true) {
const { data: records, meta } = await bcGet(`/catalog/products/${productId}/variants`, {
limit: PAGE_LIMIT,
page,
});
allRecords.push(...records);
const pagination = meta.pagination || {};
if (!records.length || page >= (pagination.total_pages || page)) return allRecords;
page += 1;
}
}
export async function run() {
const affected = [];
let scanned = 0;
for await (const productId of allProductIds()) {
scanned += 1;
const { recordsFetched, total: expectedTotal } = await probeVariantsUnpaginated(productId);
if (!isTruncated(recordsFetched, null, expectedTotal)) continue;
console.warn(
`product_id=${productId} truncated: records_fetched_without_pagination=${recordsFetched} expected_total=${expectedTotal}`
);
const corrected = await fetchAllVariants(productId);
affected.push({
productId,
expectedTotal,
recordsFetchedWithoutPagination: recordsFetched,
recordsFetchedAfterRepair: corrected.length,
});
if (!DRY_RUN) {
// Re-sync only this product's mirrored SKU rows here, using `corrected`.
console.log(`product_id=${productId} re-synced with ${corrected.length} records.`);
} else {
console.log(
`product_id=${productId} would re-sync ${corrected.length} records (DRY_RUN=true, no write performed).`
);
}
}
console.log(
`Done. Scanned ${scanned} product(s). ${affected.length} product(s) were truncated at the implicit 50-record default.`
);
for (const row of affected) {
console.log(
`REPORT product_id=${row.productId} expected_total=${row.expectedTotal} ` +
`records_fetched_without_pagination=${row.recordsFetchedWithoutPagination} ` +
`records_fetched_after_repair=${row.recordsFetchedAfterRepair}`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether a product's SKU list gets treated as trustworthy or gets a full paginated re-fetch. Because is_truncated takes only plain values and returns a plain boolean, the test needs no network and no BigCommerce store. It just feeds in a few record counts and totals and checks the answer.
from reconcile_truncated_skus import is_truncated
def test_no_limit_requested_and_records_equal_default_and_total_exceeds_it():
assert is_truncated(50, None, 80) is True
def test_no_limit_requested_and_total_equals_records_fetched():
assert is_truncated(50, None, 50) is False
def test_no_limit_requested_and_records_fetched_under_default():
assert is_truncated(30, None, 30) is False
def test_explicit_limit_requested_and_records_fall_short_of_true_total():
assert is_truncated(200, 250, 260) is True
def test_explicit_limit_requested_and_records_match_the_smaller_of_limit_and_total():
assert is_truncated(100, 250, 100) is False
def test_explicit_limit_requested_and_records_match_the_limit_itself():
assert is_truncated(250, 250, 250) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isTruncated } from "./reconcile-truncated-skus.js";
test("no limit requested and records equal the default while total exceeds it", () => {
assert.equal(isTruncated(50, null, 80), true);
});
test("no limit requested and total equals records fetched", () => {
assert.equal(isTruncated(50, null, 50), false);
});
test("no limit requested and records fetched under the default", () => {
assert.equal(isTruncated(30, null, 30), false);
});
test("explicit limit requested and records fall short of the true total", () => {
assert.equal(isTruncated(200, 250, 260), true);
});
test("explicit limit requested and records match the smaller of limit and total", () => {
assert.equal(isTruncated(100, 250, 100), false);
});
test("explicit limit requested and records match the limit itself", () => {
assert.equal(isTruncated(250, 250, 250), false);
});
Case studies
The migration script that silently dropped 30 SKUs per bulk product
A merchant migrating from another platform ran a one-time script against a legacy BigCommerce API client library to pull every SKU for every product into a new pricing engine. The client library's getSkus(productId) helper made one call and returned whatever came back. Products with under 50 variants imported perfectly. Every bulk-configured product, size and color matrices well past 50 combinations, quietly imported only its first 50 SKUs, and the pricing engine ran for weeks with incomplete variant coverage before anyone noticed prices missing on specific combinations.
Running the reconciler against the full catalog found the exact product_ids where the naive call had returned exactly 50 records against a much larger meta.pagination.total, then re-fetched each one fully paginated. The pricing engine was re-synced only for those flagged product_ids, using the corrected list.
The team that trusted the wrong endpoint during a version upgrade
A store migrating integration code from GET /v2/products/{id}/skus to GET /v3/catalog/products/{id}/variants tested the new endpoint against a small set of simple products and confirmed the shape of the response matched what the old code expected. The team shipped the change without re-testing against the store's largest configurable products, which carried well over 50 variants each.
The reconciler caught it on the first scheduled run after deploy, before a downstream inventory sync job ever picked up the incomplete variant lists. Every affected product_id was flagged with its expected total and its truncated count, and the corrected fetch ran automatically once the report had been reviewed.
After this runs on a schedule, no product's SKU or variant count is trusted from a single unpaginated call. Every product is probed the same way a naive integration would probe it, any product where the truncation signature shows up gets a full, correctly paginated re-fetch, and the report shows exactly which product_ids were affected, what the true total was, and how many records the corrected fetch actually returned, so nothing downstream ever silently works off a 50-record slice of a larger list again.
FAQ
Why does the BigCommerce SKUs or variants endpoint only return 50 records?
GET /v2/products/{id}/skus and GET /v3/catalog/products/{id}/variants are paginated collection endpoints. When the limit query parameter is omitted, BigCommerce silently defaults it to 50 per page, with a documented maximum of 250. A client that calls the endpoint once without limit or page, or without reading meta.pagination.total_pages, only ever sees the first 50 records for any product that has more.
How do I know a product's SKU or variant list was actually truncated?
Compare the number of records a single unpaginated call returned against meta.pagination.total from that same response. If the call returned exactly 50 records and meta.pagination.total is greater than 50, that combination is the truncation signature. The safest permanent check is to always read meta.pagination.total_pages and loop until every page has been fetched, regardless of how many records came back on the first page.
Should the fix rewrite or delete the SKU records that were missed?
No. Truncation is a read-path defect, not a data-integrity problem, so there is nothing on the BigCommerce side to repair. The correct fix is to always paginate fully using limit=250 and looping until page is greater than or equal to meta.pagination.total_pages. If a downstream system, such as a mirrored SKU table, was populated from the truncated call, re-sync only the affected product_ids using the complete, fully paginated result set, guarded by a DRY_RUN flag before writing anything.
Related field notes
Citations
On the problem:
- Issue with products with more than 50 Skus, bigcommerce-api-php. github.com bigcommerce-api-php issue #187
- Pagination on a subresource, bigcommerce-api-python. github.com bigcommerce-api-python issue #18
- BigCommerce Developer Center: Product SKU (V2 legacy reference). developer.bigcommerce.com v2 product sku
On the solution:
- BigCommerce Developer Center: Product Variants (V3). developer.bigcommerce.com product variants
- BigCommerce Docs: List Products, pagination params (limit, page, meta.pagination). docs.bigcommerce.com list products
- BigCommerce Support: Platform Limits. support.bigcommerce.com platform limits
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 catch a silently truncated SKU list?
If this saved you from a pricing or inventory job quietly working off half a product's variants, 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