Reconciler Orders

BigCommerce order count endpoint disagrees with actual paginated order list

One script calls GET /v2/orders/count and gets back a tidy number. Another script pages all the way through GET /v2/orders and counts as it goes. The two totals do not match, and nothing in either response tells you why. The gap is almost never store-data corruption. It is two calls quietly applying two different implicit status_id filters, plus the fact that a count is a snapshot while a multi-page scan takes real time. Here is why that happens and a small reconciler that localizes the mismatch instead of guessing at it.

Python and Node.js BigCommerce V2 Orders API Report only, no writes
A cardboard box sits on a textured surface.
Photo by Anastasiya Doicheva on Unsplash
The short answer

GET /v2/orders/count and GET /v2/orders both accept the same filters, status_id, min_date_created, max_date_created, customer_id, and both apply an implicit default scope when status_id is omitted. Incomplete orders (status_id 0, abandoned at payment) are commonly excluded from an unfiltered count's default scope but still show up in an unfiltered pagination scan of /v2/orders, so a script that calls one endpoint with no filters and the other with a different filter set is comparing two different result sets. A secondary cause is timing: count is a point-in-time snapshot, and a multi-page scan on a large store can take long enough for orders to be created or cancelled in between. The fix is not to trust either single number. Call GET /v2/orders/count?status_id={id} for each of the 15 known status_id values, sum them, and compare that sum against a full paginated scan using the same filters. Any remaining delta gets reported per status_id for a human to review, never auto-repaired.

The problem in plain words

GET /v2/orders/count looks like a shortcut. Call it once, get {"count": N}, done. GET /v2/orders is the workhorse, you page through it 250 rows at a time to actually list orders. Both endpoints take the same query parameters, status_id, min_date_created, max_date_created, customer_id, so it is natural to assume that calling /count with no filters and then paginating /v2/orders with no filters returns the same total. They often do not.

The catch is what happens when status_id is left out. Some client code paths, and some store configurations, treat an unfiltered order count as "all customer-facing orders," which quietly excludes status_id 0, Incomplete, since those are carts abandoned mid-payment and were never meant to show up in storefront-facing order totals. Meanwhile an unfiltered pagination of /v2/orders has no such carve-out built in and will return every order row it can see, Incomplete included. Sum fifteen different status buckets by hand and you can get a third number that matches neither of the first two, because the buckets were never queried with a consistent filter set to begin with.

On top of the filter mismatch, a full pagination scan is not instantaneous. On a large store, paging through every order at limit=250 per page can take seconds to minutes. If an order is created, cancelled, or deleted while that scan is running, the count you fetched before the scan started is already stale by the time the last page comes back, even if the filters were identical.

GET /v2/orders/count no status_id, excludes 0 Paginate GET /v2/orders no filter, includes 0 Two different implicit scopes Totals disagree status_id 0 is the usual gap
Both endpoints accept the same filters, but an unfiltered count and an unfiltered pagination scan can each apply a different implicit default, most often around Incomplete (status_id 0) orders.

Why it happens

This is a recurring question on BigCommerce's own support forum, developers see /v2/orders/count return a number that a script's paginated total does not match, and the API reference does not call out the implicit default behavior in the response itself. See the citations at the end for the exact threads and docs.

The key insight

Neither /v2/orders/count nor a single pagination run is the source of truth on its own. The source of truth is what you get when you query both endpoints with the identical filter set and compare. Call GET /v2/orders/count?status_id={id} for each of the 15 known status_id values, sum them, and separately sum len(orders) from a full pagination of /v2/orders using those same status_id filters. If the two totals still disagree after the filters are aligned and the scan is re-run within a short window, the mismatch is real and localized to specific status_id buckets, and that is a report for a human, not something to auto-repair.

The fix, as a flow

We do not touch any order record. We add a reconciler that queries the count endpoint per status_id, fully paginates the order list with the same filters, and compares the two, bucket by bucket, emitting a dry run report of exactly where they disagree.

Count per status_id /count?status_id=0..14 Paginate all orders same filters, tally status_id Reconcile per bucket delta = count - paginated All deltas zero? yes Consistent no Dry run report mismatched status_id list
The reconciler never writes anything. It only produces a report of which status_id buckets disagree, for a human to review before escalating.

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. Orders (read-only) scope is enough, since this reconciler never writes. 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 MIN_DATE_CREATED=""   # optional, e.g. "2026-01-01"
export DRY_RUN="true"        # this reconciler only ever reports, never writes
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIN_DATE_CREATED=""   // optional, e.g. "2026-01-01"
export DRY_RUN="true"        // this reconciler only ever reports, never writes
2

Talk to the V2 Orders REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v2/ with the token in the X-Auth-Token header. A small helper handles GET and raises on a non-2xx response. We reuse it for both the count endpoint and the paginated order list.

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}/v2"

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 []
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}/v2`;

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) : [];
}
3

Fetch a count per status_id, and fully paginate the order list

Call GET /v2/orders/count?status_id={id} for each of the 15 known status_id values, 0 through 14, passing the same min_date_created filter each time if you use one. Separately, fully paginate GET /v2/orders?page={p}&limit=250&sort=id:asc with the same filters, incrementing page until a page returns fewer than 250 rows or an empty array, and tally each order's status_id as you go.

step3.py
ALL_STATUS_IDS = list(range(15))  # 0 Incomplete .. 14 Partially Refunded

def count_by_status(min_date_created=None):
    totals = {}
    for status_id in ALL_STATUS_IDS:
        params = {"status_id": status_id}
        if min_date_created:
            params["min_date_created"] = min_date_created
        body = bc_get("/orders/count", params)
        totals[status_id] = body.get("count", 0)
    return totals

def paginate_all_order_status_ids(min_date_created=None):
    status_ids = []
    page = 1
    while True:
        params = {"page": page, "limit": 250, "sort": "id:asc"}
        if min_date_created:
            params["min_date_created"] = min_date_created
        orders = bc_get("/orders", params)
        if not orders:
            return status_ids
        for order in orders:
            status_ids.append(order["status_id"])
        if len(orders) < 250:
            return status_ids
        page += 1
step3.js
const ALL_STATUS_IDS = Array.from({ length: 15 }, (_, i) => i); // 0 Incomplete .. 14 Partially Refunded

async function countByStatus(minDateCreated) {
  const totals = {};
  for (const statusId of ALL_STATUS_IDS) {
    const params = { status_id: statusId };
    if (minDateCreated) params.min_date_created = minDateCreated;
    const body = await bcGet("/orders/count", params);
    totals[statusId] = body.count || 0;
  }
  return totals;
}

async function paginateAllOrderStatusIds(minDateCreated) {
  const statusIds = [];
  let page = 1;
  while (true) {
    const params = { page, limit: 250, sort: "id:asc" };
    if (minDateCreated) params.min_date_created = minDateCreated;
    const orders = await bcGet("/orders", params);
    if (!orders.length) return statusIds;
    for (const order of orders) statusIds.push(order.status_id);
    if (orders.length < 250) return statusIds;
    page += 1;
  }
}
4

Reconcile with one pure function

Keep the comparison in its own function that takes the per-status count map and the flat list of status_id values seen while paginating, and returns a plain report. No network, no BigCommerce client, just two data structures already fetched by the caller. This is what makes it fully unit-testable with synthetic inputs.

reconcile.py
from collections import Counter
from dataclasses import dataclass, field

@dataclass
class ReconciliationReport:
    total_count_endpoint: int
    total_paginated: int
    per_status_deltas: dict
    mismatched_status_ids: list
    is_consistent: bool

def reconcile_order_counts(count_endpoint_totals: dict, paginated_order_status_ids: list) -> ReconciliationReport:
    paginated_counts = Counter(paginated_order_status_ids)
    all_status_ids = set(count_endpoint_totals) | set(paginated_counts)

    per_status_deltas = {}
    for status_id in all_status_ids:
        expected = count_endpoint_totals.get(status_id, 0)
        actual = paginated_counts.get(status_id, 0)
        per_status_deltas[status_id] = expected - actual

    mismatched_status_ids = [sid for sid, delta in per_status_deltas.items() if delta != 0]

    return ReconciliationReport(
        total_count_endpoint=sum(count_endpoint_totals.values()),
        total_paginated=len(paginated_order_status_ids),
        per_status_deltas=per_status_deltas,
        mismatched_status_ids=mismatched_status_ids,
        is_consistent=all(delta == 0 for delta in per_status_deltas.values()),
    )
reconcile.js
export function reconcileOrderCounts(countEndpointTotals, paginatedOrderStatusIds) {
  const paginatedCounts = new Map();
  for (const statusId of paginatedOrderStatusIds) {
    paginatedCounts.set(statusId, (paginatedCounts.get(statusId) || 0) + 1);
  }

  const allStatusIds = new Set([
    ...Object.keys(countEndpointTotals).map(Number),
    ...paginatedCounts.keys(),
  ]);

  const perStatusDeltas = {};
  for (const statusId of allStatusIds) {
    const expected = countEndpointTotals[statusId] || 0;
    const actual = paginatedCounts.get(statusId) || 0;
    perStatusDeltas[statusId] = expected - actual;
  }

  const mismatchedStatusIds = Object.keys(perStatusDeltas)
    .map(Number)
    .filter((sid) => perStatusDeltas[sid] !== 0);

  return {
    totalCountEndpoint: Object.values(countEndpointTotals).reduce((a, b) => a + b, 0),
    totalPaginated: paginatedOrderStatusIds.length,
    perStatusDeltas,
    mismatchedStatusIds,
    isConsistent: mismatchedStatusIds.length === 0,
  };
}
5

Re-check the timing window, then emit the report

Before trusting a mismatch, re-run the per-status count calls immediately after pagination completes and compare that against the first snapshot. If the two count snapshots agree with each other but not with the paginated total, the mismatch is real, not a timing artifact from orders created or cancelled mid-scan. Either way, log the full report, unfiltered_count, sum_per_status_count, paginated_total, and mismatched_status_ids, for a human. Never write anything.

apply.py
def log_report(report):
    print(f"unfiltered_count={report.total_count_endpoint}")
    print(f"paginated_total={report.total_paginated}")
    print(f"is_consistent={report.is_consistent}")
    if not report.is_consistent:
        print(f"mismatched_status_ids={report.mismatched_status_ids}")
        for status_id in report.mismatched_status_ids:
            print(f"  status_id={status_id} delta={report.per_status_deltas[status_id]}")
apply.js
function logReport(report) {
  console.log(`unfiltered_count=${report.totalCountEndpoint}`);
  console.log(`paginated_total=${report.totalPaginated}`);
  console.log(`is_consistent=${report.isConsistent}`);
  if (!report.isConsistent) {
    console.log(`mismatched_status_ids=${JSON.stringify(report.mismatchedStatusIds)}`);
    for (const statusId of report.mismatchedStatusIds) {
      console.log(`  status_id=${statusId} delta=${report.perStatusDeltas[statusId]}`);
    }
  }
}
6

Wire it together, report only, never write

The run loop fetches the per-status counts, fully paginates the order list with the same filters, re-fetches the per-status counts a second time to rule out timing drift, and reconciles all three. This reconciler has no write path at all. Its only output is a report. If it finds a genuine, timing-adjusted mismatch, the next step is a human decision, for example escalating to BigCommerce support with the store hash, date range, and the specific status_id deltas, never an automatic delete or status change.

Report, do not repair

A count mismatch is a query-semantics or timing signal, not proof that order data is corrupted. Never delete or modify an order based on a count discrepancy alone. Always align filters first, re-run within a short window to rule out concurrency drift, and only then treat a remaining delta as something to escalate.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, sums per-status counts, fully paginates the order list with the same filters, reconciles the two, and re-checks the count snapshot after the scan to separate a real mismatch from ordinary concurrency drift. It never writes to a single order.

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

reconcile_order_counts.py
"""Reconcile a BigCommerce order count mismatch between /v2/orders/count and /v2/orders.

GET /v2/orders/count and GET /v2/orders both accept status_id, min_date_created,
max_date_created, and customer_id, and both apply an implicit default scope when
status_id is omitted. Incomplete orders (status_id 0, abandoned at payment) are
commonly excluded from an unfiltered count's default scope but still appear in an
unfiltered pagination scan, so a script calling one endpoint with no filters and
the other with a different filter set ends up comparing two different result
sets. A secondary cause is timing: count is a point-in-time snapshot, while a
multi-page scan can take seconds to minutes on a large store. This job sums
per-status counts across all 15 status_id values, fully paginates the order list
with the same filters, reconciles the two totals bucket by bucket, and re-checks
the count snapshot after pagination to rule out concurrency drift. It only ever
reports. It never deletes or modifies an order based on a count mismatch alone.

Guide: https://www.allanninal.dev/bigcommerce/order-count-endpoint-mismatch/
"""
import os
import logging
from collections import Counter
from dataclasses import dataclass

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
MIN_DATE_CREATED = os.environ.get("MIN_DATE_CREATED") or None
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ALL_STATUS_IDS = list(range(15))  # 0 Incomplete .. 14 Partially Refunded
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()
    if not r.text:
        return []
    return r.json()


@dataclass
class ReconciliationReport:
    total_count_endpoint: int
    total_paginated: int
    per_status_deltas: dict
    mismatched_status_ids: list
    is_consistent: bool


def reconcile_order_counts(count_endpoint_totals: dict, paginated_order_status_ids: list) -> ReconciliationReport:
    """Pure comparison. No network, no side effects.

    count_endpoint_totals maps status_id -> count returned by
    GET /v2/orders/count?status_id={id} for each of the 15 status_id values.
    paginated_order_status_ids is the flat list of status_id values collected by
    fully paginating GET /v2/orders with the same filters. Returns a plain report
    with the two grand totals, a per-status delta map, the list of status_id
    values where the two disagree, and whether every bucket balances.
    """
    paginated_counts = Counter(paginated_order_status_ids)
    all_status_ids = set(count_endpoint_totals) | set(paginated_counts)

    per_status_deltas = {}
    for status_id in all_status_ids:
        expected = count_endpoint_totals.get(status_id, 0)
        actual = paginated_counts.get(status_id, 0)
        per_status_deltas[status_id] = expected - actual

    mismatched_status_ids = sorted(sid for sid, delta in per_status_deltas.items() if delta != 0)

    return ReconciliationReport(
        total_count_endpoint=sum(count_endpoint_totals.values()),
        total_paginated=len(paginated_order_status_ids),
        per_status_deltas=per_status_deltas,
        mismatched_status_ids=mismatched_status_ids,
        is_consistent=all(delta == 0 for delta in per_status_deltas.values()),
    )


def count_by_status():
    totals = {}
    for status_id in ALL_STATUS_IDS:
        params = {"status_id": status_id}
        if MIN_DATE_CREATED:
            params["min_date_created"] = MIN_DATE_CREATED
        body = bc_get("/orders/count", params)
        totals[status_id] = body.get("count", 0)
    return totals


def paginate_all_order_status_ids():
    status_ids = []
    page = 1
    while True:
        params = {"page": page, "limit": PAGE_LIMIT, "sort": "id:asc"}
        if MIN_DATE_CREATED:
            params["min_date_created"] = MIN_DATE_CREATED
        orders = bc_get("/orders", params)
        if not orders:
            return status_ids
        for order in orders:
            status_ids.append(order["status_id"])
        if len(orders) < PAGE_LIMIT:
            return status_ids
        page += 1


def log_report(label, report):
    log.info("[%s] unfiltered_count=%s paginated_total=%s is_consistent=%s",
              label, report.total_count_endpoint, report.total_paginated, report.is_consistent)
    if not report.is_consistent:
        for status_id in report.mismatched_status_ids:
            log.warning("[%s] status_id=%s delta=%s", label, status_id, report.per_status_deltas[status_id])


def run():
    log.info("Fetching per-status counts before pagination (DRY_RUN=%s, report only, no writes).", DRY_RUN)
    counts_before = count_by_status()

    paginated_status_ids = paginate_all_order_status_ids()

    log.info("Fetching per-status counts again after pagination to check for concurrency drift.")
    counts_after = count_by_status()

    report_before = reconcile_order_counts(counts_before, paginated_status_ids)
    report_after = reconcile_order_counts(counts_after, paginated_status_ids)

    log_report("pre-scan snapshot", report_before)
    log_report("post-scan snapshot", report_after)

    if report_before.is_consistent and report_after.is_consistent:
        log.info("Consistent. Counts and pagination agree across all status_id buckets.")
        return

    if not report_before.is_consistent and report_after.is_consistent:
        log.info("Mismatch resolved by the post-scan snapshot. Likely concurrency drift during the scan window.")
        return

    log.warning(
        "Persistent mismatch after re-checking the count snapshot. mismatched_status_ids=%s. "
        "This is a report for a human, escalate to BigCommerce support with store_hash=%s, "
        "min_date_created=%s, and the mismatched status_id list. No orders were modified.",
        report_after.mismatched_status_ids, STORE_HASH, MIN_DATE_CREATED,
    )


if __name__ == "__main__":
    run()
reconcile-order-counts.js
/**
 * Reconcile a BigCommerce order count mismatch between /v2/orders/count and /v2/orders.
 *
 * GET /v2/orders/count and GET /v2/orders both accept status_id, min_date_created,
 * max_date_created, and customer_id, and both apply an implicit default scope when
 * status_id is omitted. Incomplete orders (status_id 0, abandoned at payment) are
 * commonly excluded from an unfiltered count's default scope but still appear in an
 * unfiltered pagination scan, so a script calling one endpoint with no filters and
 * the other with a different filter set ends up comparing two different result
 * sets. A secondary cause is timing: count is a point-in-time snapshot, while a
 * multi-page scan can take seconds to minutes on a large store. This job sums
 * per-status counts across all 15 status_id values, fully paginates the order list
 * with the same filters, reconciles the two totals bucket by bucket, and re-checks
 * the count snapshot after pagination to rule out concurrency drift. It only ever
 * reports. It never deletes or modifies an order based on a count mismatch alone.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/order-count-endpoint-mismatch/
 */
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}/v2`;
const MIN_DATE_CREATED = process.env.MIN_DATE_CREATED || null;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const ALL_STATUS_IDS = Array.from({ length: 15 }, (_, i) => i); // 0 Incomplete .. 14 Partially Refunded
const PAGE_LIMIT = 250;

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

/**
 * Pure comparison. No network, no side effects.
 *
 * countEndpointTotals maps statusId -> count returned by
 * GET /v2/orders/count?status_id={id} for each of the 15 status_id values.
 * paginatedOrderStatusIds is the flat list of status_id values collected by
 * fully paginating GET /v2/orders with the same filters. Returns a plain report
 * with the two grand totals, a per-status delta map, the list of status_id
 * values where the two disagree, and whether every bucket balances.
 */
export function reconcileOrderCounts(countEndpointTotals, paginatedOrderStatusIds) {
  const paginatedCounts = new Map();
  for (const statusId of paginatedOrderStatusIds) {
    paginatedCounts.set(statusId, (paginatedCounts.get(statusId) || 0) + 1);
  }

  const allStatusIds = new Set([
    ...Object.keys(countEndpointTotals).map(Number),
    ...paginatedCounts.keys(),
  ]);

  const perStatusDeltas = {};
  for (const statusId of allStatusIds) {
    const expected = countEndpointTotals[statusId] || 0;
    const actual = paginatedCounts.get(statusId) || 0;
    perStatusDeltas[statusId] = expected - actual;
  }

  const mismatchedStatusIds = Object.keys(perStatusDeltas)
    .map(Number)
    .filter((sid) => perStatusDeltas[sid] !== 0)
    .sort((a, b) => a - b);

  return {
    totalCountEndpoint: Object.values(countEndpointTotals).reduce((a, b) => a + b, 0),
    totalPaginated: paginatedOrderStatusIds.length,
    perStatusDeltas,
    mismatchedStatusIds,
    isConsistent: mismatchedStatusIds.length === 0,
  };
}

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 countByStatus() {
  const totals = {};
  for (const statusId of ALL_STATUS_IDS) {
    const params = { status_id: statusId };
    if (MIN_DATE_CREATED) params.min_date_created = MIN_DATE_CREATED;
    const body = await bcGet("/orders/count", params);
    totals[statusId] = body.count || 0;
  }
  return totals;
}

async function paginateAllOrderStatusIds() {
  const statusIds = [];
  let page = 1;
  while (true) {
    const params = { page, limit: PAGE_LIMIT, sort: "id:asc" };
    if (MIN_DATE_CREATED) params.min_date_created = MIN_DATE_CREATED;
    const orders = await bcGet("/orders", params);
    if (!orders.length) return statusIds;
    for (const order of orders) statusIds.push(order.status_id);
    if (orders.length < PAGE_LIMIT) return statusIds;
    page += 1;
  }
}

function logReport(label, report) {
  console.log(`[${label}] unfiltered_count=${report.totalCountEndpoint} paginated_total=${report.totalPaginated} is_consistent=${report.isConsistent}`);
  if (!report.isConsistent) {
    for (const statusId of report.mismatchedStatusIds) {
      console.warn(`[${label}] status_id=${statusId} delta=${report.perStatusDeltas[statusId]}`);
    }
  }
}

export async function run() {
  console.log(`Fetching per-status counts before pagination (DRY_RUN=${DRY_RUN}, report only, no writes).`);
  const countsBefore = await countByStatus();

  const paginatedStatusIds = await paginateAllOrderStatusIds();

  console.log("Fetching per-status counts again after pagination to check for concurrency drift.");
  const countsAfter = await countByStatus();

  const reportBefore = reconcileOrderCounts(countsBefore, paginatedStatusIds);
  const reportAfter = reconcileOrderCounts(countsAfter, paginatedStatusIds);

  logReport("pre-scan snapshot", reportBefore);
  logReport("post-scan snapshot", reportAfter);

  if (reportBefore.isConsistent && reportAfter.isConsistent) {
    console.log("Consistent. Counts and pagination agree across all status_id buckets.");
    return;
  }

  if (!reportBefore.isConsistent && reportAfter.isConsistent) {
    console.log("Mismatch resolved by the post-scan snapshot. Likely concurrency drift during the scan window.");
    return;
  }

  console.warn(
    `Persistent mismatch after re-checking the count snapshot. mismatchedStatusIds=${JSON.stringify(reportAfter.mismatchedStatusIds)}. ` +
    `This is a report for a human, escalate to BigCommerce support with store_hash=${STORE_HASH}, ` +
    `min_date_created=${MIN_DATE_CREATED}, and the mismatched status_id list. No orders were modified.`
  );
}

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

Add a test

The reconciler function is the part most worth testing, because it decides whether the store's counts are actually consistent. Because reconcile_order_counts takes only plain dicts and lists and returns a plain data object, the test needs no network and no BigCommerce store. It just feeds in synthetic count maps and status_id lists and checks the answer.

test_order_count_reconciler.py
from reconcile_order_counts import reconcile_order_counts


def test_is_consistent_when_every_bucket_matches():
    counts = {0: 3, 1: 5, 2: 2}
    paginated = [0, 0, 0, 1, 1, 1, 1, 1, 2, 2]
    report = reconcile_order_counts(counts, paginated)
    assert report.is_consistent is True
    assert report.mismatched_status_ids == []
    assert report.total_count_endpoint == 10
    assert report.total_paginated == 10


def test_flags_status_id_zero_when_incomplete_orders_are_missing_from_count():
    counts = {0: 0, 1: 5}  # count endpoint excluded Incomplete orders
    paginated = [0, 0, 1, 1, 1, 1, 1]  # pagination still saw them
    report = reconcile_order_counts(counts, paginated)
    assert report.is_consistent is False
    assert report.mismatched_status_ids == [0]
    assert report.per_status_deltas[0] == -2
    assert report.per_status_deltas[1] == 0


def test_handles_status_id_present_only_in_pagination():
    counts = {1: 2}
    paginated = [1, 1, 5]
    report = reconcile_order_counts(counts, paginated)
    assert report.mismatched_status_ids == [5]
    assert report.per_status_deltas[5] == -1


def test_handles_status_id_present_only_in_count_endpoint():
    counts = {1: 2, 9: 4}
    paginated = [1, 1]
    report = reconcile_order_counts(counts, paginated)
    assert report.mismatched_status_ids == [9]
    assert report.per_status_deltas[9] == 4


def test_empty_inputs_are_consistent():
    report = reconcile_order_counts({}, [])
    assert report.is_consistent is True
    assert report.total_count_endpoint == 0
    assert report.total_paginated == 0
reconcile-order-counts.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileOrderCounts } from "./reconcile-order-counts.js";

test("is consistent when every bucket matches", () => {
  const counts = { 0: 3, 1: 5, 2: 2 };
  const paginated = [0, 0, 0, 1, 1, 1, 1, 1, 2, 2];
  const report = reconcileOrderCounts(counts, paginated);
  assert.equal(report.isConsistent, true);
  assert.deepEqual(report.mismatchedStatusIds, []);
  assert.equal(report.totalCountEndpoint, 10);
  assert.equal(report.totalPaginated, 10);
});

test("flags status_id 0 when Incomplete orders are missing from count", () => {
  const counts = { 0: 0, 1: 5 }; // count endpoint excluded Incomplete orders
  const paginated = [0, 0, 1, 1, 1, 1, 1]; // pagination still saw them
  const report = reconcileOrderCounts(counts, paginated);
  assert.equal(report.isConsistent, false);
  assert.deepEqual(report.mismatchedStatusIds, [0]);
  assert.equal(report.perStatusDeltas[0], -2);
  assert.equal(report.perStatusDeltas[1], 0);
});

test("handles a status_id present only in pagination", () => {
  const counts = { 1: 2 };
  const paginated = [1, 1, 5];
  const report = reconcileOrderCounts(counts, paginated);
  assert.deepEqual(report.mismatchedStatusIds, [5]);
  assert.equal(report.perStatusDeltas[5], -1);
});

test("handles a status_id present only in the count endpoint", () => {
  const counts = { 1: 2, 9: 4 };
  const paginated = [1, 1];
  const report = reconcileOrderCounts(counts, paginated);
  assert.deepEqual(report.mismatchedStatusIds, [9]);
  assert.equal(report.perStatusDeltas[9], 4);
});

test("empty inputs are consistent", () => {
  const report = reconcileOrderCounts({}, []);
  assert.equal(report.isConsistent, true);
  assert.equal(report.totalCountEndpoint, 0);
  assert.equal(report.totalPaginated, 0);
});

Case studies

Incomplete orders

The store where the dashboard total never matched the export

A merchant's finance team exported all orders via a script that paginated GET /v2/orders with no filters, while an internal dashboard called GET /v2/orders/count once a day with no filters either. The export always had a few dozen more rows than the dashboard's number, every single day, and nobody could explain the gap without opening both tools side by side.

Running the reconciler localized it immediately: status_id 0, Incomplete, was the entire delta. The dashboard's count call excluded abandoned-at-payment orders by convention, the export's pagination did not filter status_id at all and picked them up. Once both were run with an explicit status_id list, the numbers matched every time.

Timing drift

The large catalog where the scan itself took minutes

A high-volume store's nightly reconciliation job paginated tens of thousands of orders, a scan that took close to four minutes end to end. The count snapshot taken at the start of the run was reliably a handful of orders lower than the paginated total, because new orders kept coming in from checkout while the scan was still running.

Re-running the per-status count immediately after the scan finished, and comparing that second snapshot instead of the first, made the discrepancy disappear. The reconciler now takes both snapshots automatically and only flags a mismatch that survives the post-scan recheck, so ordinary order volume during a long scan no longer triggers a false alarm.

What good looks like

After this runs, nobody has to guess whether a store's order counts are "close enough." Every status_id bucket is checked against both the count endpoint and a full paginated scan using identical filters, timing drift from a long scan is ruled out with a second snapshot, and if a genuine mismatch remains it comes with the exact status_id and delta needed to escalate, instead of a single confusing number that disagrees with another single confusing number.

FAQ

Why does GET /v2/orders/count not match the number of orders I get from paginating /v2/orders?

Both endpoints accept the same status_id, min_date_created, max_date_created, and customer_id filters, and both apply an implicit default when you omit status_id. If your count call and your pagination loop do not pass the exact same filters, you end up comparing two different result sets, most commonly because Incomplete orders (status_id 0, abandoned at payment) are excluded from one call's default scope but included in the other's unfiltered scan.

Should I trust one unfiltered GET /v2/orders/count call for a store-wide total?

No. A single unfiltered count call depends on an implicit default you do not control and cannot verify from the response alone. Sum the per-status counts by calling GET /v2/orders/count?status_id={id} for each of the 15 known status_id values and compare that sum against a full paginated scan with the same filters.

If the counts still do not match after aligning filters, should the script delete or fix orders automatically?

No. A count mismatch is a query-semantics or timing signal, not proof of data corruption. The correct response is to emit a dry run report with the unfiltered count, the per-status sum, the paginated total, and the specific order id deltas grouped by status_id, so a human can decide whether to escalate to BigCommerce support before anything is changed.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: why does the Get a Count of Orders API call return incorrect counts. support.bigcommerce.com why does get a count of orders return incorrect counts
  2. BigCommerce Support: API V2 not displaying all orders. support.bigcommerce.com api v2 not displaying all orders
  3. BigCommerce Support: how to get the number of orders. support.bigcommerce.com how to get number of orders

On the solution:

  1. BigCommerce API Reference: Orders V2, status_id, and the count and list endpoints. docs.bigcommerce.com orders v2
  2. BigCommerce API Reference: List Orders, pagination and filters. docs.bigcommerce.com list orders
  3. BigCommerce Developer Center: Orders overview. developer.bigcommerce.com orders overview

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 clear up a confusing order count?

If this saved you from chasing a phantom data bug that was actually a filter mismatch, 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