Reconciler Fulfillment and shipping

Partial shipment total mismatch

A WMS or 3PL posted shipments in batches as pickers worked through the warehouse, and now the order does not add up. It says Partially Shipped when every unit clearly left the building, or a line shows a shipped quantity that does not match what was ordered or what got refunded. The customer already has their package, but BigCommerce's own bookkeeping disagrees with itself. Here is why the numbers drift apart and a small script that finds the mismatched orders, safely fixes the ones that are safe to fix, and flags the rest for a human.

Python and Node.js BigCommerce REST API Safe by default (dry run, narrow auto-fix)
The short answer

BigCommerce computes an order's fulfillment state by summing quantity_shipped across each line item on GET /v2/orders/{id}/products and rolling that up into the order's status_id. Because shipments are created incrementally through separate POST /v2/orders/{id}/shipments calls, one per pick and pack batch, a duplicated call, a shipment posted against an already-refunded line, or a dropped webhook retry can push the shipped total above or leave it below the true ordered quantity. Run a small Python or Node.js script that pulls each candidate order's line items and its independent shipment ledger, classifies the mismatch with one pure function, safely flips the status_id for the well-defined stuck-partial cases behind a dry run guard, and flags true ledger disagreements for a human to reconcile against the WMS instead of touching shipment records. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce does not store a single flag that says an order is Partially Shipped. It infers it. Every line item on an order carries an ordered quantity and a quantity_shipped counter, and the order's own status_id gets rolled up from comparing those two numbers across every line: 2 for Shipped when everything is shipped, 3 for Partially Shipped when only some of it is.

The shipments themselves are not one atomic event. A warehouse ships an order in batches, one pick and pack run at a time, and each batch calls POST /v2/orders/{id}/shipments with an order_product_id and a partial quantity. That is a reasonable design for how warehouses actually work, but it means the shipped total for a line is really just the sum of however many shipment calls happened to land. If an external WMS or 3PL integration double-posts a batch, drops a webhook retry and resends the same tracking payload, or ships against a line item that was already partially refunded, the sum quietly drifts from the truth, and BigCommerce has no built in step that reconciles it after the fact.

WMS ships batch 1 POST .../shipments WMS ships batch 2 POST .../shipments retry duplicates batch 2 Sum quantity_shipped on the order_product Shipped > ordered over-fulfilled line status_id stuck or wrong, no auto reconcile
Every shipment call is a small truth on its own. Nothing sums them and checks the total against the order after the fact, so a duplicate or dropped call persists silently.

Why it happens

The order status is only as good as the shipment calls that fed it, and nothing forces those calls to be exactly right. A few common ways stores end up with a mismatch:

This is a common source of confusion for support teams. The customer has their package, tracking looks fine in the carrier's own system, but the BigCommerce order still reads Partially Shipped, or a shipping report shows a shipped quantity nobody can explain. BigCommerce's shipment-create validation only rejects a single request that exceeds the remaining shippable quantity for that call. It does not look back across every prior shipment and refund to check the total still makes sense, and a refund never rolls back a shipment record automatically. See the citations at the end for the exact threads and docs.

The key insight

Not every mismatch is the same kind of problem. Some are pure bookkeeping, the status_id just never caught up even though the shipped quantity is exactly right, or exactly zero, and those are safe to correct automatically. Others are a real disagreement between BigCommerce's cached quantity_shipped and the actual shipment records, or a line that shipped and got refunded more than it was ever ordered, and those can only be resolved by a human who can see the WMS side, since correcting them by script risks re-shipping units or under-crediting a customer. The fix has to tell these apart before it writes anything.

The fix, as a flow

We do not touch shipment records, ever. We add a job that walks candidate orders, pulls each line's ordered, shipped, and refunded quantities plus an independently summed shipment ledger, classifies the mismatch with one pure function, and only writes a corrected status_id for the two safe cases. Every other kind of mismatch gets a note on the order for a human to reconcile against the WMS.

Scheduled job runs on a timer List candidates status_id 2, 3, 14 Read products + ledger quantity, shipped, refunded stuck_partial or drift/over? stuck_partial drift or over_fulfilled Correct status_id 2 or 11, dry run guarded Flag for review staff_notes, no shipment edit
The script writes exactly two kinds of thing. A corrected status_id for the safe cases, or a review note for everything else. It never deletes or edits a shipment record.

Build it step by step

1

Get a BigCommerce API access token

Create an API account in your BigCommerce control panel under Settings, API, and generate a token with the Orders read and write scopes. Keep the store hash and the token in environment variables, never in the file.

setup (shell)
pip install requests

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

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

Talk to the BigCommerce REST API

Every call carries your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper sends the request, raises on a non-2xx response, and returns the parsed body. We reuse this helper to read orders, line items, and shipments, and later to write the corrected status.

step2.py
import os, requests

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

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}
3

List candidate orders and read each side of the ledger

Pull orders with status_id 3 (Partially Shipped), 2 (Shipped, to catch over-shipment), and 14 (Partially Refunded, since refunds shrink the shippable pool), filtered by min_date_modified for a lookback window. For each order, read GET /v2/orders/{id}/products for the per-line quantity, quantity_shipped, and quantity_refunded, then GET /v2/orders/{id}/shipments and sum items[].quantity grouped by order_product_id to get an independently computed shipped total per line.

step3.py
CANDIDATE_STATUS_IDS = {2, 3, 14}

def orders_to_check(min_date_modified):
    page = 1
    while True:
        rows = bc("GET", f"/v2/orders?min_date_modified={min_date_modified}&page={page}&limit=50")
        if not rows:
            return
        for row in rows:
            if int(row["status_id"]) in CANDIDATE_STATUS_IDS:
                yield row
        page += 1

def order_line_items(order_id):
    return bc("GET", f"/v2/orders/{order_id}/products") or []

def shipment_ledger_by_line(order_id):
    shipments = bc("GET", f"/v2/orders/{order_id}/shipments") or []
    totals = {}
    for shipment in shipments:
        for item in shipment.get("items", []):
            key = item["order_product_id"]
            totals[key] = totals.get(key, 0) + int(item["quantity"])
    return totals
step3.js
const CANDIDATE_STATUS_IDS = new Set([2, 3, 14]);

async function* ordersToCheck(minDateModified) {
  let page = 1;
  while (true) {
    const rows = await bc("GET", `/v2/orders?min_date_modified=${minDateModified}&page=${page}&limit=50`);
    if (!rows || rows.length === 0) return;
    for (const row of rows) {
      if (CANDIDATE_STATUS_IDS.has(Number(row.status_id))) yield row;
    }
    page++;
  }
}

async function orderLineItems(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
}

async function shipmentLedgerByLine(orderId) {
  const shipments = (await bc("GET", `/v2/orders/${orderId}/shipments`)) || [];
  const totals = {};
  for (const shipment of shipments) {
    for (const item of shipment.items || []) {
      const key = item.order_product_id;
      totals[key] = (totals[key] || 0) + Number(item.quantity);
    }
  }
  return totals;
}
4

Decide, with one pure function

Keep the classification in its own function that takes the ordered quantity, the cached quantity_shipped, quantity_refunded, the independently summed shipment ledger quantity, and the order's status_id, and returns a label. Ledger drift and over-fulfillment are data integrity issues and take priority, since they mean the shipment records themselves disagree with reality. The stuck-partial cases only fire when the ledger and the cached counter already agree, which is what makes them safe to auto-correct.

decide.py
def classify_shipment_mismatch(ordered_qty, quantity_shipped, quantity_refunded,
                                shipment_ledger_qty, order_status_id):
    if shipment_ledger_qty != quantity_shipped:
        return "ledger_drift"
    if quantity_shipped + quantity_refunded > ordered_qty:
        return "over_fulfilled"
    if order_status_id == 3 and quantity_shipped == ordered_qty:
        return "stuck_partial_done"
    if order_status_id == 3 and quantity_shipped == 0 and quantity_refunded == 0:
        return "stuck_partial_unshipped"
    return "ok"
decide.js
export function classifyShipmentMismatch(orderedQty, quantityShipped, quantityRefunded,
                                          shipmentLedgerQty, orderStatusId) {
  if (shipmentLedgerQty !== quantityShipped) return "ledger_drift";
  if (quantityShipped + quantityRefunded > orderedQty) return "over_fulfilled";
  if (orderStatusId === 3 && quantityShipped === orderedQty) return "stuck_partial_done";
  if (orderStatusId === 3 && quantityShipped === 0 && quantityRefunded === 0) return "stuck_partial_unshipped";
  return "ok";
}
5

Correct the safe cases, flag the rest

For stuck_partial_done, write status_id 2 (Shipped) since every line has fully shipped. For stuck_partial_unshipped, write status_id 11 (Awaiting Fulfillment) since nothing has actually left the building yet. Both writes go through PUT /v2/orders/{id} and both are gated by DRY_RUN. For ledger_drift and over_fulfilled, never touch a shipment record or the status. Instead append a note to staff_notes so a human can reconcile the order against the WMS.

apply.py
CORRECTED_STATUS_ID = {"stuck_partial_done": 2, "stuck_partial_unshipped": 11}

def correct_status(order_id, verdict):
    return bc("PUT", f"/v2/orders/{order_id}", json={"status_id": CORRECTED_STATUS_ID[verdict]})

def flag_for_review(order_id, verdict, line_summary):
    note = f"SHIPMENT_MISMATCH[{verdict}]: {line_summary}"
    return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})
apply.js
const CORRECTED_STATUS_ID = { stuck_partial_done: 2, stuck_partial_unshipped: 11 };

async function correctStatus(orderId, verdict) {
  return bc("PUT", `/v2/orders/${orderId}`, { status_id: CORRECTED_STATUS_ID[verdict] });
}

async function flagForReview(orderId, verdict, lineSummary) {
  const note = `SHIPMENT_MISMATCH[${verdict}]: ${lineSummary}`;
  return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}
6

Wire it together with a dry run guard

The loop pulls each candidate order, reads its line items and shipment ledger, classifies every line, and only writes when DRY_RUN is off. Leave DRY_RUN=true on the first runs so the script only logs what it would do. Read the log, confirm it against a few orders you already know about, then switch it off. Run it on a schedule that matches how fast your WMS posts shipments, for example once an hour.

Run it safe

Always start with DRY_RUN=true. Never let this script delete, edit, or recreate a shipment record, since shipment records drive customer shipment-confirmation emails and 3PL sync. Only correct status_id for stuck_partial_done and stuck_partial_unshipped, the two cases where the ledger already agrees with quantity_shipped. Everything else is a job for a human with visibility into the WMS.

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, corrects only the two safe stuck-partial cases, and never edits a shipment record.

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

find_shipment_mismatch.py
"""Reconcile BigCommerce orders whose shipped quantity does not add up.

An order's status_id is rolled up from summing quantity_shipped across its
line items on GET /v2/orders/{id}/products. Shipments are created in batches
through POST /v2/orders/{id}/shipments, so a duplicated call, a shipment
posted against an already-refunded line, or a dropped webhook retry can push
the shipped total above or leave it below the true ordered quantity. This
reads each candidate order's line items and its independent shipment ledger,
classifies the mismatch with classify_shipment_mismatch, safely corrects the
status_id for the two well-defined stuck-partial cases, and flags every other
mismatch to staff_notes for a human to reconcile against the WMS. It never
deletes, edits, or recreates a shipment record. Run on a schedule. Safe to
run again and again.
"""
import os
import logging
import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
MIN_DATE_MODIFIED = os.environ.get("MIN_DATE_MODIFIED", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CANDIDATE_STATUS_IDS = {2, 3, 14}
CORRECTED_STATUS_ID = {"stuck_partial_done": 2, "stuck_partial_unshipped": 11}
AUTO_CORRECT_VERDICTS = set(CORRECTED_STATUS_ID)


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    return r.json() if r.content else None


def classify_shipment_mismatch(ordered_qty: int, quantity_shipped: int, quantity_refunded: int,
                                shipment_ledger_qty: int, order_status_id: int) -> str:
    """
    Pure decision logic, no I/O. Returns one of:
      "ledger_drift"        -> BC's cached quantity_shipped disagrees with the sum of shipment records (case a)
      "over_fulfilled"      -> shipped + refunded exceeds what was ordered (case b)
      "stuck_partial_done"  -> fully shipped per ledger but status_id still 3 (case c)
      "stuck_partial_unshipped" -> nothing shipped/refunded but status_id is 3 (case d)
      "ok"                  -> no mismatch detected

    Precedence: ledger_drift and over_fulfilled (data integrity issues) take priority
    over the status-only issues (c/d), which are safe to auto-correct.
    """
    if shipment_ledger_qty != quantity_shipped:
        return "ledger_drift"
    if quantity_shipped + quantity_refunded > ordered_qty:
        return "over_fulfilled"
    if order_status_id == 3 and quantity_shipped == ordered_qty:
        return "stuck_partial_done"
    if order_status_id == 3 and quantity_shipped == 0 and quantity_refunded == 0:
        return "stuck_partial_unshipped"
    return "ok"


def orders_to_check():
    page = 1
    while True:
        params = f"page={page}&limit=50"
        if MIN_DATE_MODIFIED:
            params += f"&min_date_modified={MIN_DATE_MODIFIED}"
        rows = bc("GET", f"/v2/orders?{params}")
        if not rows:
            return
        for row in rows:
            if int(row["status_id"]) in CANDIDATE_STATUS_IDS:
                yield row
        page += 1


def order_line_items(order_id):
    return bc("GET", f"/v2/orders/{order_id}/products") or []


def shipment_ledger_by_line(order_id):
    shipments = bc("GET", f"/v2/orders/{order_id}/shipments") or []
    totals = {}
    for shipment in shipments:
        for item in shipment.get("items", []):
            key = item["order_product_id"]
            totals[key] = totals.get(key, 0) + int(item["quantity"])
    return totals


def correct_status(order_id, verdict):
    return bc("PUT", f"/v2/orders/{order_id}", json={"status_id": CORRECTED_STATUS_ID[verdict]})


def flag_for_review(order_id, verdict, line_summary):
    note = f"SHIPMENT_MISMATCH[{verdict}]: {line_summary}"
    return bc("PUT", f"/v2/orders/{order_id}", json={"staff_notes": note})


def run():
    corrected = 0
    flagged = 0
    for row in orders_to_check():
        order_id = row["id"]
        status_id = int(row["status_id"])
        ledger = shipment_ledger_by_line(order_id)
        for line in order_line_items(order_id):
            line_id = line["id"]
            ordered_qty = int(line["quantity"])
            quantity_shipped = int(line.get("quantity_shipped", 0))
            quantity_refunded = int(line.get("quantity_refunded", 0))
            ledger_qty = ledger.get(line_id, 0)
            verdict = classify_shipment_mismatch(
                ordered_qty, quantity_shipped, quantity_refunded, ledger_qty, status_id
            )
            if verdict == "ok":
                continue
            summary = (f"order={order_id} line={line_id} ordered={ordered_qty} "
                       f"shipped={quantity_shipped} refunded={quantity_refunded} "
                       f"ledger={ledger_qty} status_id={status_id}")
            if verdict in AUTO_CORRECT_VERDICTS:
                log.info("%s. %s %s", verdict, summary, "would correct" if DRY_RUN else "correcting")
                if not DRY_RUN:
                    correct_status(order_id, verdict)
                corrected += 1
            else:
                log.warning("%s. %s %s", verdict, summary, "would flag" if DRY_RUN else "flagging")
                if not DRY_RUN:
                    flag_for_review(order_id, verdict, summary)
                flagged += 1
    log.info(
        "Done. %d order(s) %s, %d order(s) %s.",
        corrected, "to correct" if DRY_RUN else "corrected",
        flagged, "to flag" if DRY_RUN else "flagged",
    )


if __name__ == "__main__":
    run()
find-shipment-mismatch.js
/**
 * Reconcile BigCommerce orders whose shipped quantity does not add up.
 *
 * An order's status_id is rolled up from summing quantity_shipped across its
 * line items on GET /v2/orders/{id}/products. Shipments are created in batches
 * through POST /v2/orders/{id}/shipments, so a duplicated call, a shipment
 * posted against an already-refunded line, or a dropped webhook retry can push
 * the shipped total above or leave it below the true ordered quantity. This
 * reads each candidate order's line items and its independent shipment ledger,
 * classifies the mismatch with classifyShipmentMismatch, safely corrects the
 * status_id for the two well-defined stuck-partial cases, and flags every other
 * mismatch to staff_notes for a human to reconcile against the WMS. It never
 * deletes, edits, or recreates a shipment record. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/partial-shipment-total-mismatch/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const MIN_DATE_MODIFIED = process.env.MIN_DATE_MODIFIED || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CANDIDATE_STATUS_IDS = new Set([2, 3, 14]);
const CORRECTED_STATUS_ID = { stuck_partial_done: 2, stuck_partial_unshipped: 11 };
const AUTO_CORRECT_VERDICTS = new Set(Object.keys(CORRECTED_STATUS_ID));

/**
 * Pure decision logic, no I/O. Returns one of:
 *   "ledger_drift"            BC's cached quantity_shipped disagrees with the sum of shipment records (case a)
 *   "over_fulfilled"          shipped + refunded exceeds what was ordered (case b)
 *   "stuck_partial_done"      fully shipped per ledger but status_id still 3 (case c)
 *   "stuck_partial_unshipped" nothing shipped/refunded but status_id is 3 (case d)
 *   "ok"                      no mismatch detected
 *
 * Precedence: ledger_drift and over_fulfilled (data integrity issues) take priority
 * over the status-only issues (c/d), which are safe to auto-correct.
 */
export function classifyShipmentMismatch(orderedQty, quantityShipped, quantityRefunded,
                                          shipmentLedgerQty, orderStatusId) {
  if (shipmentLedgerQty !== quantityShipped) return "ledger_drift";
  if (quantityShipped + quantityRefunded > orderedQty) return "over_fulfilled";
  if (orderStatusId === 3 && quantityShipped === orderedQty) return "stuck_partial_done";
  if (orderStatusId === 3 && quantityShipped === 0 && quantityRefunded === 0) return "stuck_partial_unshipped";
  return "ok";
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}

async function* ordersToCheck() {
  let page = 1;
  while (true) {
    let params = `page=${page}&limit=50`;
    if (MIN_DATE_MODIFIED) params += `&min_date_modified=${MIN_DATE_MODIFIED}`;
    const rows = await bc("GET", `/v2/orders?${params}`);
    if (!rows || rows.length === 0) return;
    for (const row of rows) {
      if (CANDIDATE_STATUS_IDS.has(Number(row.status_id))) yield row;
    }
    page++;
  }
}

async function orderLineItems(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
}

async function shipmentLedgerByLine(orderId) {
  const shipments = (await bc("GET", `/v2/orders/${orderId}/shipments`)) || [];
  const totals = {};
  for (const shipment of shipments) {
    for (const item of shipment.items || []) {
      const key = item.order_product_id;
      totals[key] = (totals[key] || 0) + Number(item.quantity);
    }
  }
  return totals;
}

async function correctStatus(orderId, verdict) {
  return bc("PUT", `/v2/orders/${orderId}`, { status_id: CORRECTED_STATUS_ID[verdict] });
}

async function flagForReview(orderId, verdict, lineSummary) {
  const note = `SHIPMENT_MISMATCH[${verdict}]: ${lineSummary}`;
  return bc("PUT", `/v2/orders/${orderId}`, { staff_notes: note });
}

export async function run() {
  let corrected = 0;
  let flagged = 0;
  for await (const row of ordersToCheck()) {
    const orderId = row.id;
    const statusId = Number(row.status_id);
    const ledger = await shipmentLedgerByLine(orderId);
    const lines = await orderLineItems(orderId);
    for (const line of lines) {
      const lineId = line.id;
      const orderedQty = Number(line.quantity);
      const quantityShipped = Number(line.quantity_shipped || 0);
      const quantityRefunded = Number(line.quantity_refunded || 0);
      const ledgerQty = ledger[lineId] || 0;
      const verdict = classifyShipmentMismatch(
        orderedQty, quantityShipped, quantityRefunded, ledgerQty, statusId
      );
      if (verdict === "ok") continue;
      const summary = `order=${orderId} line=${lineId} ordered=${orderedQty} shipped=${quantityShipped} refunded=${quantityRefunded} ledger=${ledgerQty} status_id=${statusId}`;
      if (AUTO_CORRECT_VERDICTS.has(verdict)) {
        console.log(`${verdict}. ${summary} ${DRY_RUN ? "would correct" : "correcting"}`);
        if (!DRY_RUN) await correctStatus(orderId, verdict);
        corrected++;
      } else {
        console.warn(`${verdict}. ${summary} ${DRY_RUN ? "would flag" : "flagging"}`);
        if (!DRY_RUN) await flagForReview(orderId, verdict, summary);
        flagged++;
      }
    }
  }
  console.log(
    `Done. ${corrected} order(s) ${DRY_RUN ? "to correct" : "corrected"}, ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`
  );
}

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

Add a test

The classification rule is the part most worth testing, because it decides which orders get an automatic write and which ones get handed to a human. Because we kept classify_shipment_mismatch pure, plain integers in, one string out, the test needs no network and no BigCommerce store. It just feeds in numbers and checks the answer.

test_partial_classify.py
from find_shipment_mismatch import classify_shipment_mismatch


def test_ok_when_everything_ties_out_and_not_partial():
    assert classify_shipment_mismatch(10, 10, 0, 10, 2) == "ok"


def test_ok_when_partial_and_consistent():
    assert classify_shipment_mismatch(10, 4, 0, 4, 3) == "ok"


def test_ledger_drift_when_ledger_disagrees_with_cached_counter():
    assert classify_shipment_mismatch(10, 6, 0, 8, 3) == "ledger_drift"


def test_over_fulfilled_when_shipped_plus_refunded_exceeds_ordered():
    assert classify_shipment_mismatch(10, 8, 5, 8, 2) == "over_fulfilled"


def test_stuck_partial_done_when_fully_shipped_but_status_still_partial():
    assert classify_shipment_mismatch(10, 10, 0, 10, 3) == "stuck_partial_done"


def test_stuck_partial_unshipped_when_nothing_moved_but_status_is_partial():
    assert classify_shipment_mismatch(10, 0, 0, 0, 3) == "stuck_partial_unshipped"


def test_ledger_drift_takes_priority_over_stuck_partial_done():
    # shipped equals ordered, but the ledger itself disagrees, so drift wins
    assert classify_shipment_mismatch(10, 10, 0, 7, 3) == "ledger_drift"


def test_over_fulfilled_takes_priority_when_ledger_also_agrees():
    assert classify_shipment_mismatch(10, 9, 2, 9, 2) == "over_fulfilled"
shipment-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyShipmentMismatch } from "./find-shipment-mismatch.js";

test("ok when everything ties out and not partial", () => {
  assert.equal(classifyShipmentMismatch(10, 10, 0, 10, 2), "ok");
});

test("ok when partial and consistent", () => {
  assert.equal(classifyShipmentMismatch(10, 4, 0, 4, 3), "ok");
});

test("ledger drift when ledger disagrees with cached counter", () => {
  assert.equal(classifyShipmentMismatch(10, 6, 0, 8, 3), "ledger_drift");
});

test("over fulfilled when shipped plus refunded exceeds ordered", () => {
  assert.equal(classifyShipmentMismatch(10, 8, 5, 8, 2), "over_fulfilled");
});

test("stuck partial done when fully shipped but status still partial", () => {
  assert.equal(classifyShipmentMismatch(10, 10, 0, 10, 3), "stuck_partial_done");
});

test("stuck partial unshipped when nothing moved but status is partial", () => {
  assert.equal(classifyShipmentMismatch(10, 0, 0, 0, 3), "stuck_partial_unshipped");
});

test("ledger drift takes priority over stuck partial done", () => {
  assert.equal(classifyShipmentMismatch(10, 10, 0, 7, 3), "ledger_drift");
});

test("over fulfilled takes priority when ledger also agrees", () => {
  assert.equal(classifyShipmentMismatch(10, 9, 2, 9, 2), "over_fulfilled");
});

Case studies

Duplicate WMS retry

The order stuck on Partially Shipped forever

A merchant's 3PL integration would occasionally time out waiting on BigCommerce's response to a shipment call, then retry the same batch a few minutes later. Most retries were harmless because the first call had actually failed, but a handful landed after a first call that had actually succeeded, doubling the shipped quantity for that line while a sibling line in the same order never shipped at all. The order sat on status_id 3 indefinitely and support had no clean way to tell which lines were the real problem.

Running the reconciliation job surfaced the doubled line as ledger_drift, since the summed shipment items disagreed with the cached counter, and it was flagged in staff_notes rather than silently corrected. The unshipped sibling line, once it actually shipped for real, resolved on its own on the next run.

Refund after ship

The line that was shipped and refunded past what was ordered

A customer ordered five units of an item, three shipped in the first pick wave, and support issued a two-unit refund for a pricing complaint before the last two units ever shipped. The WMS, still holding the original ordered quantity of five, later shipped the remaining two anyway, pushing the shipped total to five while the refunded quantity independently sat at two, five shipped plus two refunded is more than the five that were ordered.

The script classified this as over_fulfilled and flagged it rather than guessing whether to reverse the shipment or the refund, since only a human with the support ticket in hand could tell which side needed correcting.

What good looks like

After this runs on a schedule, orders that are truly done stop sitting on Partially Shipped for no reason, and orders that never actually started stop pretending they are in progress, both corrected automatically and safely. Every real ledger disagreement or over-fulfillment gets a note with the exact numbers a human needs to reconcile against the WMS, and not a single shipment record is ever touched by the script itself.

FAQ

Why does a BigCommerce order stay Partially Shipped forever?

BigCommerce rolls up an order's status_id from the sum of quantity_shipped across its line items compared to each line's ordered quantity. If a WMS or 3PL integration never sent the final shipment call, or a shipment was duplicated or posted against a line that was already refunded, the sums can disagree with reality and the order gets stuck on status_id 3 (Partially Shipped) even when every unit has shipped, or shows nothing shipped at all.

Is it safe to auto-correct a partial shipment mismatch?

Only for the safe cases. When quantity_shipped truly equals the ordered quantity across every line but status_id is still 3, or when nothing has shipped or been refunded but the order sits on status_id 3, it is safe to correct the status_id with PUT /v2/orders/{id}. When the shipment ledger itself disagrees with BigCommerce's cached quantity_shipped, or shipped plus refunded exceeds what was ordered, do not touch shipment records automatically, flag the order for a human instead.

How do I detect a partial shipment mismatch on a BigCommerce order?

Pull GET /v2/orders/{id}/products for each line's quantity, quantity_shipped, and quantity_refunded, then GET /v2/orders/{id}/shipments and sum items[].quantity per order_product_id. Compare that independently computed total against quantity_shipped, check whether quantity_shipped plus quantity_refunded exceeds the ordered quantity, and check whether the order's status_id still shows 3 after every line has fully shipped or after nothing has shipped.

Related field notes

Citations

On the problem:

  1. BigCommerce Community: Partially Shipped. support.bigcommerce.com/s/topic/partially-shipped
  2. BigCommerce Community: while creating an order shipment, "the quantity specified is greater than the quantity of the product that is available to ship". support.bigcommerce.com quantity is invalid
  3. BigCommerce Help Center: Order Statuses. support.bigcommerce.com/s/article/Order-Statuses

On the solution:

  1. BigCommerce API Reference: Order Shipments. developer.bigcommerce.com/docs/rest-management/orders/order-shipments
  2. BigCommerce API Reference: Order Products. developer.bigcommerce.com/docs/rest-management/orders/order-products
  3. BigCommerce Developer Center: Orders Overview. developer.bigcommerce.com/docs/store-operations/orders

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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 untangle a stuck shipment?

If this saved you a confusing support ticket or a stuck order report, 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