Reconciler Orders
Order shipments response drops the items array
The raw shipment JSON from BigCommerce has the shipped lines right there, an items array of order_product_id, product_id, and quantity. But the object your code actually works with shows items as null, empty, or missing entirely. Nothing is wrong on the BigCommerce store. A mapping layer built around the shipment's flat scalar fields quietly left the one nested array out. Here is why that happens and a small reconciler that catches it before it silently breaks your shipped-quantity reporting.
BigCommerce's V2 shipment object, from GET /v2/orders/{order_id}/shipments or GET /v2/orders/{order_id}/shipments/{shipment_id}, nests the shipped order lines inside an items array of {order_product_id, product_id, quantity}, sitting alongside flat scalar fields like tracking_number, order_address_id, and comments. Client code that maps the response through a fixed schema, a typed model or DTO, or a column-style allowlist built for the common scalar fields can easily leave items out, since it is a nested array and not a top-level scalar. The result is a mapped shipment object whose items is missing, null, or an empty list, even though the raw JSON body still has the shipped lines. Run a small Python or Node.js reconciler that parses the raw JSON directly, compares it against the mapped object, and flags any shipment where a non-empty raw items array became empty or missing after mapping. No write call is made against the shipment, because the BigCommerce data was never wrong. Full code, tests, and a dry run guard are below.
The problem in plain words
A BigCommerce shipment record covers one shipment event on one order: who it went to, a tracking number, a carrier, a date, and which order lines it actually shipped. Everything except that last piece is a flat field. tracking_number is a string. order_address_id is a number. shipping_provider is a string. Those map cleanly onto whatever column, attribute, or field a typed model expects.
The shipped lines do not fit that shape. They live in a nested array called items, where each entry is its own small object: order_product_id, product_id, and quantity. When a team builds an SDK wrapper, an ORM-style resource class, or even just a fixed list of keys they pull off the response body, it is easy to enumerate the scalar fields you can see in a spreadsheet-style export and forget the one field that is actually a list of objects. Nothing throws an error. The mapper just returns an object with no items, or an items that is null, or an empty list, and every downstream reader treats that as "nothing was shipped."
The gap usually stays invisible until something reconciles the mapped object against the truth, either the raw JSON body itself or GET /v2/orders/{order_id}/products, which reports quantity_shipped per order line independently of the shipment mapper. That is usually a reporting job, a fulfillment export, or an analytics pipeline, and by the time someone notices, shipped quantities have already been under-reported for a while.
Why it happens
This is a client-side mapping gap, not anything broken in BigCommerce's own data. A few common ways it creeps in:
- A typed model or DTO class was written against the shipment's obvious scalar fields, tracking_number, shipping_provider, tracking_carrier, comments, and the nested items array was never added as a mapped attribute.
- An ORM-style resource wrapper (for example a Python SDK's Shipment resource) exposes attributes generated from a fixed schema or column list, and items, being a list of objects rather than a column-like scalar, gets skipped or defaults to an empty value.
- A hand-rolled "pluck these keys off the response" allowlist was written by looking at one example shipment in a browser or Postman, and whoever wrote it grabbed the fields that looked like a normal row and skipped the one that looked like a sub-resource.
- Serialization back out, for logging, a database column, or an internal API, uses the same narrow schema, so even if the mapper technically read items at some point, it gets dropped again on the way to storage.
The defect is quiet by design. Every scalar field the store depends on for the shipping label, the tracking email, and the shipment history still looks correct. Only the shipped-quantity side, the thing a reconciliation or analytics job actually needs, is wrong. See the citations at the end for the exact BigCommerce docs and SDK source that show the real shape of the response.
Do not trust the mapped object's items field on its own. Parse the raw JSON body directly, the same body BigCommerce actually sent, and compare its items array against what the mapper produced. If the raw body has a non-empty items list and the mapped object does not, that is drift, and it means somewhere a schema, DTO, or allowlist is silently dropping data. Cross-check against GET /v2/orders/{order_id}/products, which reports quantity_shipped independently, to confirm the shipped lines are real and not an artifact of a bad reconciliation script.
The fix, as a flow
We do not write anything back to the shipment. BigCommerce's stored shipment record is correct, so there is nothing to repair there. Instead we add a reconciliation job that reads both the raw JSON and the mapped object for every shipment, decides whether drift happened, and produces a report a human or a downstream job can act on.
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. Grant it Orders (read-only is enough here) scope so it can read shipments and order products. 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" # start safe, change to false to cross-check order-products
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" // start safe, change to false to cross-check order-products
Talk to the V2 Order Shipments REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v2/ with the token in the X-Auth-Token header and Accept: application/json. A small helper handles GET and raises on a non-2xx response. Critically, this helper hands back the raw parsed JSON body, the exact same shape BigCommerce sent, with nothing stripped out.
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_raw(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 []
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 bcGetRaw(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) : [];
}
List shipments and simulate the narrow mapper
Call GET /v2/orders/{order_id}/shipments to get the raw shipment list for an order. To reproduce the bug for detection, also run each raw shipment through a stand-in for whatever scalar-only mapper your integration uses, one that copies id, order_id, tracking_number, order_address_id, and comments, but never touches items. That mirrors the exact allowlist gap this field note is about.
def order_shipments(order_id):
return bc_get_raw(f"/orders/{order_id}/shipments")
def order_products(order_id):
return bc_get_raw(f"/orders/{order_id}/products")
# Stand-in for a narrow scalar-only mapper that omits the nested items array.
SCALAR_FIELDS = ("id", "order_id", "customer_id", "order_address_id",
"date_created", "tracking_number", "shipping_provider",
"tracking_carrier", "comments")
def map_shipment_scalars_only(raw_shipment):
return {key: raw_shipment.get(key) for key in SCALAR_FIELDS}
async function orderShipments(orderId) {
return bcGetRaw(`/orders/${orderId}/shipments`);
}
async function orderProducts(orderId) {
return bcGetRaw(`/orders/${orderId}/products`);
}
// Stand-in for a narrow scalar-only mapper that omits the nested items array.
const SCALAR_FIELDS = ["id", "order_id", "customer_id", "order_address_id",
"date_created", "tracking_number", "shipping_provider",
"tracking_carrier", "comments"];
function mapShipmentScalarsOnly(rawShipment) {
const mapped = {};
for (const key of SCALAR_FIELDS) mapped[key] = rawShipment[key];
return mapped;
}
Decide, with one pure function
Keep the decision in its own function that takes the raw shipment and the mapped shipment and returns a drift record or nothing. It only flags a real problem: a non-empty raw items array that became null, empty, or non-list after mapping. A shipment where nothing was shipped in the raw data is not a drift case, there is nothing to lose.
def find_items_drift(raw_shipment: dict, mapped_shipment: dict) -> dict | None:
raw_items = raw_shipment.get("items") or []
mapped_items = mapped_shipment.get("items")
if not isinstance(raw_items, list) or len(raw_items) == 0:
return None # nothing shipped in raw; not a drift case
if mapped_items is None or mapped_items == [] or not isinstance(mapped_items, list):
raw_qty = sum(int(i.get("quantity", 0)) for i in raw_items)
return {
"shipment_id": raw_shipment.get("id"),
"order_id": raw_shipment.get("order_id"),
"raw_item_count": len(raw_items),
"raw_shipped_quantity": raw_qty,
"mapped_items_value": mapped_items,
"order_product_ids": [i.get("order_product_id") for i in raw_items],
}
return None
export function findItemsDrift(rawShipment, mappedShipment) {
const rawItems = rawShipment.items || [];
const mappedItems = mappedShipment.items;
if (!Array.isArray(rawItems) || rawItems.length === 0) {
return null; // nothing shipped in raw; not a drift case
}
if (mappedItems == null || (Array.isArray(mappedItems) && mappedItems.length === 0) || !Array.isArray(mappedItems)) {
const rawQty = rawItems.reduce((sum, i) => sum + Number(i.quantity || 0), 0);
return {
shipmentId: rawShipment.id,
orderId: rawShipment.order_id,
rawItemCount: rawItems.length,
rawShippedQuantity: rawQty,
mappedItemsValue: mappedItems,
orderProductIds: rawItems.map((i) => i.order_product_id),
};
}
return null;
}
Cross-check against order products
Before trusting a drift record, cross-check the raw shipped quantities against GET /v2/orders/{order_id}/products, which returns each order line's quantity_shipped independently of the shipment mapper. If the numbers agree, the shipped lines are real and it is only the mapped object that lost them. This step is read-only and only runs when DRY_RUN is false, since it is an extra confirmation call, not a required one.
def cross_check_quantity_shipped(order_id, order_product_ids):
products = order_products(order_id)
by_id = {p.get("id"): p.get("quantity_shipped") for p in products}
return {opid: by_id.get(opid) for opid in order_product_ids}
async function crossCheckQuantityShipped(orderId, orderProductIds) {
const products = await orderProducts(orderId);
const byId = new Map(products.map((p) => [p.id, p.quantity_shipped]));
const result = {};
for (const opid of orderProductIds) result[opid] = byId.get(opid);
return result;
}
Wire it together with a dry run guard
The loop ties every piece together. It never writes to the shipment, since the shipment on BigCommerce's side was never broken. Notice the dry run guard: with DRY_RUN=true, it emits the drift report and stops there. With DRY_RUN=false, it additionally cross-checks each drift record's order_product_ids against GET /v2/orders/{order_id}/products so the report includes confirmed quantity_shipped values alongside the raw shipment's numbers.
This job never issues a PUT, POST, or DELETE against /v2/orders/{order_id}/shipments. There is nothing to fix on the BigCommerce side. If find_items_drift keeps firing on your store, the actual fix is a code change in whatever SDK wrapper, DTO, or allowlist is mapping the shipment response, adding items to the fields it keeps.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never writes to a shipment because there is nothing on BigCommerce's side to repair.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find BigCommerce order shipments whose items array was dropped by a mapper.
BigCommerce's V2 shipment object, from GET /v2/orders/{order_id}/shipments,
nests the shipped order lines inside an items array of order_product_id,
product_id, and quantity, alongside flat scalar fields like tracking_number
and order_address_id. Client integrations that map the response through a
fixed schema, a typed model or DTO, or a column-style allowlist built for the
common scalar fields can easily leave items out, since it is a nested array
and not a top-level scalar. The mapped object then shows items as missing,
null, or an empty list even though the raw JSON body still has the shipped
lines. This is a client-side parsing defect, not corrupted BigCommerce data,
so this job never writes to the shipment. It only reports the drift and,
when DRY_RUN is false, cross-checks the raw shipped quantities against
GET /v2/orders/{order_id}/products to confirm the shipped lines are real.
Guide: https://www.allanninal.dev/bigcommerce/order-shipments-missing-items-array/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_shipment_items_drift")
STORE_HASH = os.environ.get("BIGCOMMERCE_STORE_HASH", "example_hash")
ACCESS_TOKEN = os.environ.get("BIGCOMMERCE_ACCESS_TOKEN", "bc_dummy")
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
# Stand-in for a narrow scalar-only mapper that omits the nested items array.
# Replace this with your real SDK/DTO mapping when wiring this into your own
# integration; the point of the reconciler is to compare THAT output against
# the raw JSON body BigCommerce actually sent.
SCALAR_FIELDS = (
"id", "order_id", "customer_id", "order_address_id",
"date_created", "tracking_number", "shipping_provider",
"tracking_carrier", "comments",
)
def bc_get_raw(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()
def order_shipments(order_id):
return bc_get_raw(f"/orders/{order_id}/shipments")
def order_products(order_id):
return bc_get_raw(f"/orders/{order_id}/products")
def map_shipment_scalars_only(raw_shipment):
"""Reproduce a scalar-only mapper that forgets the nested items array."""
return {key: raw_shipment.get(key) for key in SCALAR_FIELDS}
def find_items_drift(raw_shipment: dict, mapped_shipment: dict) -> dict | None:
"""Pure decision logic, no I/O.
raw_shipment: parsed JSON body of a single V2 shipment as returned by
GET /stores/{store_hash}/v2/orders/{order_id}/shipments/{shipment_id}
mapped_shipment: the same shipment after passing through the client
library/ORM mapper (dict-like view of its attributes)
Returns a drift record if the mapper dropped/emptied a non-empty raw
'items' array, else None.
"""
raw_items = raw_shipment.get("items") or []
mapped_items = mapped_shipment.get("items")
if not isinstance(raw_items, list) or len(raw_items) == 0:
return None # nothing shipped in raw; not a drift case
if mapped_items is None or mapped_items == [] or not isinstance(mapped_items, list):
raw_qty = sum(int(i.get("quantity", 0)) for i in raw_items)
return {
"shipment_id": raw_shipment.get("id"),
"order_id": raw_shipment.get("order_id"),
"raw_item_count": len(raw_items),
"raw_shipped_quantity": raw_qty,
"mapped_items_value": mapped_items,
"order_product_ids": [i.get("order_product_id") for i in raw_items],
}
return None
def cross_check_quantity_shipped(order_id, order_product_ids):
"""Confirm shipped quantities against order-products' quantity_shipped."""
products = order_products(order_id)
by_id = {p.get("id"): p.get("quantity_shipped") for p in products}
return {opid: by_id.get(opid) for opid in order_product_ids}
def run(order_ids):
drift_count = 0
for order_id in order_ids:
raw_shipments = order_shipments(order_id)
for raw_shipment in raw_shipments or []:
mapped_shipment = map_shipment_scalars_only(raw_shipment)
drift = find_items_drift(raw_shipment, mapped_shipment)
if drift is None:
continue
drift_count += 1
log.warning(
"Drift found: shipment_id=%s order_id=%s raw_item_count=%s "
"raw_shipped_quantity=%s mapped_items_value=%r",
drift["shipment_id"], drift["order_id"], drift["raw_item_count"],
drift["raw_shipped_quantity"], drift["mapped_items_value"],
)
if not DRY_RUN:
confirmed = cross_check_quantity_shipped(order_id, drift["order_product_ids"])
log.info(
"Cross-check for shipment_id=%s order_id=%s quantity_shipped=%s",
drift["shipment_id"], drift["order_id"], confirmed,
)
log.info("Done. %d shipment(s) with dropped items array.", drift_count)
if __name__ == "__main__":
order_ids_env = os.environ.get("ORDER_IDS", "")
order_ids = [int(x) for x in order_ids_env.split(",") if x.strip()]
run(order_ids)
/**
* Find BigCommerce order shipments whose items array was dropped by a mapper.
*
* BigCommerce's V2 shipment object, from GET /v2/orders/{order_id}/shipments,
* nests the shipped order lines inside an items array of order_product_id,
* product_id, and quantity, alongside flat scalar fields like tracking_number
* and order_address_id. Client integrations that map the response through a
* fixed schema, a typed model or DTO, or a column-style allowlist built for
* the common scalar fields can easily leave items out, since it is a nested
* array and not a top-level scalar. The mapped object then shows items as
* missing, null, or an empty list even though the raw JSON body still has the
* shipped lines. This is a client-side parsing defect, not corrupted
* BigCommerce data, so this job never writes to the shipment. It only
* reports the drift and, when DRY_RUN is false, cross-checks the raw shipped
* quantities against GET /v2/orders/{order_id}/products to confirm the
* shipped lines are real.
*
* Guide: https://www.allanninal.dev/bigcommerce/order-shipments-missing-items-array/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
// Stand-in for a narrow scalar-only mapper that omits the nested items array.
// Replace this with your real SDK/DTO mapping when wiring this into your own
// integration; the point of the reconciler is to compare THAT output against
// the raw JSON body BigCommerce actually sent.
const SCALAR_FIELDS = [
"id", "order_id", "customer_id", "order_address_id",
"date_created", "tracking_number", "shipping_provider",
"tracking_carrier", "comments",
];
/**
* Pure decision logic, no I/O.
*
* rawShipment: parsed JSON body of a single V2 shipment as returned by
* GET /stores/{store_hash}/v2/orders/{order_id}/shipments/{shipment_id}
* mappedShipment: the same shipment after passing through the client
* library/ORM mapper (dict-like view of its attributes)
* Returns a drift record if the mapper dropped/emptied a non-empty raw
* 'items' array, else null.
*/
export function findItemsDrift(rawShipment, mappedShipment) {
const rawItems = rawShipment.items || [];
const mappedItems = mappedShipment.items;
if (!Array.isArray(rawItems) || rawItems.length === 0) {
return null; // nothing shipped in raw; not a drift case
}
const mappedIsEmptyOrInvalid =
mappedItems == null ||
!Array.isArray(mappedItems) ||
mappedItems.length === 0;
if (mappedIsEmptyOrInvalid) {
const rawQty = rawItems.reduce((sum, i) => sum + Number(i.quantity || 0), 0);
return {
shipmentId: rawShipment.id,
orderId: rawShipment.order_id,
rawItemCount: rawItems.length,
rawShippedQuantity: rawQty,
mappedItemsValue: mappedItems,
orderProductIds: rawItems.map((i) => i.order_product_id),
};
}
return null;
}
async function bcGetRaw(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 orderShipments(orderId) {
return bcGetRaw(`/orders/${orderId}/shipments`);
}
async function orderProducts(orderId) {
return bcGetRaw(`/orders/${orderId}/products`);
}
function mapShipmentScalarsOnly(rawShipment) {
const mapped = {};
for (const key of SCALAR_FIELDS) mapped[key] = rawShipment[key];
return mapped;
}
async function crossCheckQuantityShipped(orderId, orderProductIds) {
const products = await orderProducts(orderId);
const byId = new Map(products.map((p) => [p.id, p.quantity_shipped]));
const result = {};
for (const opid of orderProductIds) result[opid] = byId.get(opid);
return result;
}
export async function run(orderIds) {
let driftCount = 0;
for (const orderId of orderIds) {
const rawShipments = await orderShipments(orderId);
for (const rawShipment of rawShipments || []) {
const mappedShipment = mapShipmentScalarsOnly(rawShipment);
const drift = findItemsDrift(rawShipment, mappedShipment);
if (drift === null) continue;
driftCount += 1;
console.warn(
`Drift found: shipment_id=${drift.shipmentId} order_id=${drift.orderId} ` +
`raw_item_count=${drift.rawItemCount} raw_shipped_quantity=${drift.rawShippedQuantity} ` +
`mapped_items_value=${JSON.stringify(drift.mappedItemsValue)}`
);
if (!DRY_RUN) {
const confirmed = await crossCheckQuantityShipped(orderId, drift.orderProductIds);
console.log(
`Cross-check for shipment_id=${drift.shipmentId} order_id=${drift.orderId} ` +
`quantity_shipped=${JSON.stringify(confirmed)}`
);
}
}
}
console.log(`Done. ${driftCount} shipment(s) with dropped items array.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const orderIdsEnv = process.env.ORDER_IDS || "";
const orderIds = orderIdsEnv.split(",").map((x) => x.trim()).filter(Boolean).map(Number);
run(orderIds).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a shipment gets reported as drift. Because find_items_drift takes only plain values and returns a plain object or nothing, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from find_shipment_items_drift import find_items_drift
def raw_shipment(items=None, shipment_id=1, order_id=100):
return {
"id": shipment_id,
"order_id": order_id,
"tracking_number": "1Z999AA10123456784",
"order_address_id": 5,
"items": items if items is not None else [
{"order_product_id": 11, "product_id": 200, "quantity": 2},
],
}
def test_no_drift_when_mapped_items_matches_raw():
raw = raw_shipment()
mapped = {"id": 1, "order_id": 100, "items": raw["items"]}
assert find_items_drift(raw, mapped) is None
def test_no_drift_when_raw_items_is_empty():
raw = raw_shipment(items=[])
mapped = {"id": 1, "order_id": 100}
assert find_items_drift(raw, mapped) is None
def test_drift_when_mapped_items_missing():
raw = raw_shipment()
mapped = {"id": 1, "order_id": 100, "tracking_number": "1Z999AA10123456784"}
drift = find_items_drift(raw, mapped)
assert drift is not None
assert drift["shipment_id"] == 1
assert drift["raw_item_count"] == 1
assert drift["raw_shipped_quantity"] == 2
assert drift["order_product_ids"] == [11]
def test_drift_when_mapped_items_is_null():
raw = raw_shipment()
mapped = {"id": 1, "order_id": 100, "items": None}
drift = find_items_drift(raw, mapped)
assert drift is not None
assert drift["mapped_items_value"] is None
def test_drift_when_mapped_items_is_empty_list():
raw = raw_shipment()
mapped = {"id": 1, "order_id": 100, "items": []}
drift = find_items_drift(raw, mapped)
assert drift is not None
assert drift["mapped_items_value"] == []
def test_sums_quantity_across_multiple_items():
raw = raw_shipment(items=[
{"order_product_id": 11, "product_id": 200, "quantity": 2},
{"order_product_id": 12, "product_id": 201, "quantity": 3},
])
mapped = {"id": 1, "order_id": 100}
drift = find_items_drift(raw, mapped)
assert drift["raw_shipped_quantity"] == 5
assert drift["order_product_ids"] == [11, 12]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findItemsDrift } from "./find-shipment-items-drift.js";
const rawShipment = ({ items, shipmentId = 1, orderId = 100 } = {}) => ({
id: shipmentId,
order_id: orderId,
tracking_number: "1Z999AA10123456784",
order_address_id: 5,
items: items !== undefined ? items : [
{ order_product_id: 11, product_id: 200, quantity: 2 },
],
});
test("no drift when mapped items matches raw", () => {
const raw = rawShipment();
const mapped = { id: 1, order_id: 100, items: raw.items };
assert.equal(findItemsDrift(raw, mapped), null);
});
test("no drift when raw items is empty", () => {
const raw = rawShipment({ items: [] });
const mapped = { id: 1, order_id: 100 };
assert.equal(findItemsDrift(raw, mapped), null);
});
test("drift when mapped items missing", () => {
const raw = rawShipment();
const mapped = { id: 1, order_id: 100, tracking_number: "1Z999AA10123456784" };
const drift = findItemsDrift(raw, mapped);
assert.notEqual(drift, null);
assert.equal(drift.shipmentId, 1);
assert.equal(drift.rawItemCount, 1);
assert.equal(drift.rawShippedQuantity, 2);
assert.deepEqual(drift.orderProductIds, [11]);
});
test("drift when mapped items is null", () => {
const raw = rawShipment();
const mapped = { id: 1, order_id: 100, items: null };
const drift = findItemsDrift(raw, mapped);
assert.notEqual(drift, null);
assert.equal(drift.mappedItemsValue, null);
});
test("drift when mapped items is empty list", () => {
const raw = rawShipment();
const mapped = { id: 1, order_id: 100, items: [] };
const drift = findItemsDrift(raw, mapped);
assert.notEqual(drift, null);
assert.deepEqual(drift.mappedItemsValue, []);
});
test("sums quantity across multiple items", () => {
const raw = rawShipment({
items: [
{ order_product_id: 11, product_id: 200, quantity: 2 },
{ order_product_id: 12, product_id: 201, quantity: 3 },
],
});
const mapped = { id: 1, order_id: 100 };
const drift = findItemsDrift(raw, mapped);
assert.equal(drift.rawShippedQuantity, 5);
assert.deepEqual(drift.orderProductIds, [11, 12]);
});
Case studies
The 3PL sync that thought nothing had shipped
A merchant's warehouse integration pulled shipments through an internal SDK wrapper someone had written two years earlier, mapping only the fields the original author needed for a shipping label reprint tool: tracking number, carrier, and address id. The nightly fulfillment export used that same wrapper and reported zero shipped units for orders that had, in fact, shipped days earlier.
Running the raw-versus-mapped reconciler against a sample of recent orders turned up dozens of shipments where the wrapper's items attribute was simply never populated. The BigCommerce data was fine the whole time. The fix was a one-line addition to the wrapper's field list, not anything touched on the store.
The dashboard that made it look like shipping stopped
An analytics pipeline ingested shipment records through a typed DTO generated from an older API contract. After a catalog migration added more order lines per shipment on average, the DTO's items field, already unused by the rest of the mapping, kept returning empty, and the shipped-units chart flattened out even though shipments were still going out the door.
Cross-checking the raw items array against GET /v2/orders/{order_id}/products' quantity_shipped confirmed the shipments were real. The reconciler's drift report pointed straight at the DTO as the source, and the team updated its schema instead of chasing a phantom shipping outage.
After this runs, every shipment where the raw JSON disagrees with the mapped object surfaces in a report, with the shipment id, order id, the shipped quantities the raw data actually shows, and the order_product_ids affected. No shipment record on BigCommerce is ever touched, because it was never wrong. The only fix that ever needs to happen is in your own mapping code, adding items back to the fields it keeps.
FAQ
Why does the items array disappear from a BigCommerce shipment object?
The raw V2 shipment JSON has the shipped lines nested inside an items array of order_product_id, product_id, and quantity, next to flat scalar fields like tracking_number and order_address_id. Many client integrations map the response through a fixed schema, typed model, or column-style allowlist built around the common scalar fields, and because items is a nested array rather than a top-level scalar it is easy to leave out of that mapping, so the mapped object silently drops or nulls it even though the raw response still has the data.
Is this a BigCommerce data problem I need to fix on the store?
No. The shipment record on BigCommerce's side is correct, the raw JSON always contains the items array when lines were shipped. This is a client-side parsing or mapping defect. The correct action is to flag the drift in a reconciliation report, not to send any PUT, POST, or DELETE against the shipment. If the root cause is a custom field allowlist or model schema, the real fix is a code change that adds items to the mapped field list.
How do I confirm shipped quantities are real and not just a mapping artifact?
Cross-check the raw items array's shipped quantities against GET /v2/orders/{order_id}/products, which reports quantity_shipped per order_product_id independently of the shipment mapper. If the raw shipment items agree with quantity_shipped on the order products endpoint, the shipped lines are real and only the mapped object's view of them is wrong.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: List Order Shipments, the V2 shipment object shape. developer.bigcommerce.com order-shipments
- BigCommerce API Reference: Get Shipment, the items array on a single shipment. docs.bigcommerce.com get-order-shipment
- bigcommerce-api-python: orders.py resource mapping on GitHub. github.com bigcommerce-api-python orders.py
On the solution:
- BigCommerce API Reference: Get Shipment, confirming the raw items array shape. docs.bigcommerce.com get-order-shipment
- BigCommerce API Reference: List Order Shipments. docs.bigcommerce.com get-order-shipments
- BigCommerce Developer Center: Order Products, the quantity_shipped cross-check field. developer.bigcommerce.com order-products
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 reconciliation bug for you?
If this saved your shipped-quantity reporting from a silent mapping bug, 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