Diagnostic Fulfillment and shipping

BigCommerce shipment tracking never added

A customer emails asking where their package is. You open the order and it clearly says Shipped, but the tracking link in the confirmation email goes nowhere, because there is no tracking number on file. This is not a bug. BigCommerce's shipment resource never required a tracking number in the first place, so the Ship Items modal, and any OMS or 3PL app wired up to it, can create a shipment or flip the order to Shipped without ever asking for one. Here is why that gap is allowed to happen and a small script that finds the orders it already happened to.

Python and Node.js BigCommerce V2 Orders API Safe by default (dry run)
The short answer

POST /v2/orders/{id}/shipments only requires order_address_id and items. The field tracking_number is optional, right alongside tracking_link and shipping_provider. That means the Ship Items modal in the control panel, or a connected OMS or 3PL like ShipStation, Cin7, or ShipHero, can create a shipment with the Tracking ID box left blank, or an order can be bulk moved to status_id 2, Shipped, with PUT /v2/orders/{id} and never get a shipment record at all. Run a small Python or Node.js script that lists Shipped, Partially Shipped, and Completed orders, reads each one's shipments with GET /v2/orders/{id}/shipments, and flags any order that is older than a grace window and has either no shipment record or shipment records with empty tracking fields. Full code, tests, and a dry run guard are below.

The problem in plain words

When BigCommerce built the shipment resource, it made the practical fields, the address and the items being shipped, the required ones. The carrier tracking number is treated as extra detail, not a precondition for shipping. So the API will happily accept a shipment that has no tracking_number, no tracking_link, and no shipping_provider, as long as the address and items are there.

In the control panel, that means a staff member can open the Ship Items modal, pick the items, and click Create shipment while the Tracking ID field is still empty. Nothing stops the click. On the API side, an OMS or 3PL integration can do the same thing, or skip shipment creation entirely and just push the order's status_id straight to 2, Shipped, with a plain PUT request. Either path leaves the order looking fulfilled while the one piece of information the customer actually wants, where the package is, was never recorded.

Ship Items or OMS integration Tracking ID left blank, allowed tracking_number is optional Order Shipped no tracking on file Customer link goes nowhere
The order is not lying about being shipped, but the one field customers actually need, the tracking number, was never required to get there.

Why it happens

BigCommerce's order status and its shipment records are two different things that only loosely agree with each other. A few common ways an order ends up Shipped with nothing to track:

This quietly breaks the automated shipping confirmation email, since its tracking link has nothing to point to, and it leaves the customer support team fielding "where is my order" tickets for orders that the system insists are already on their way. See the citations at the end for the exact API docs and community reports.

The key insight

status_id 2, Shipped, is a claim about state, not proof that tracking information exists. The shipment record is the real source of truth. So the safe pattern is not "trust that Shipped means trackable." It is "read each order's own shipments with GET /v2/orders/{id}/shipments and flag the ones where the shipments array is empty, or every shipment on file has an empty tracking_number, tracking_link, and shipping_provider." We also only flag orders whose date_modified is past a grace window, so we do not nag about an order the 3PL shipped an hour ago and has not posted tracking for yet.

The fix, as a flow

We do not try to invent a tracking number. We add a job that lists Shipped, Partially Shipped, and Completed orders, reads each one's shipments, and decides whether the order is missing real tracking. If it is, and the order is old enough to rule out a shipment still in progress, the job leaves a note for a human instead of guessing. Everything with real tracking is left untouched.

Scheduled job runs on a timer List Shipped orders status_id 2, 3, and 10 Read shipments per order, past grace window No shipment or no tracking? yes no, leave alone Flag for a human staff_notes or alert
The script only flags orders that are old enough to rule out a shipment still catching up on tracking, and it never fabricates a tracking number.

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. Grant it Orders (modify) scope so it can read shipments and update staff_notes. 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 LOOKBACK_DAYS="14"
export GRACE_HOURS="24"
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="..."
export LOOKBACK_DAYS="14"
export GRACE_HOURS="24"
export DRY_RUN="true"   // start safe, change to false to write
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 PUT and raises on a non-2xx response. We reuse it to list orders, read shipments, and write the flag note.

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 []

def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
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) : [];
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List Shipped orders and read their shipments

Call GET /v2/orders?status_id=2&min_date_modified=..., paginated, then repeat for status_id=3 (Partially Shipped) and status_id=10 (Completed), since an order can carry the same missing tracking forward after it moves past Shipped. For each order, call GET /v2/orders/{id}/shipments to get the shipment records the decision needs: tracking_number, tracking_link, and shipping_provider.

step3.py
SHIPPED_LIKE_STATUSES = (2, 3, 10)  # Shipped, Partially Shipped, Completed

def shipped_like_orders(lookback_days):
    for status_id in SHIPPED_LIKE_STATUSES:
        page = 1
        while True:
            orders = bc_get("/orders", {
                "status_id": status_id,
                "min_date_modified": f"-{lookback_days} days",
                "page": page,
                "limit": 250,
            })
            if not orders:
                break
            for order in orders:
                yield order
            page += 1

def order_shipments(order_id):
    return bc_get(f"/orders/{order_id}/shipments")
step3.js
const SHIPPED_LIKE_STATUSES = [2, 3, 10]; // Shipped, Partially Shipped, Completed

async function* shippedLikeOrders(lookbackDays) {
  for (const statusId of SHIPPED_LIKE_STATUSES) {
    let page = 1;
    while (true) {
      const orders = await bcGet("/orders", {
        status_id: statusId,
        min_date_modified: `-${lookbackDays} days`,
        page,
        limit: 250,
      });
      if (!orders.length) break;
      for (const order of orders) yield order;
      page += 1;
    }
  }
}

async function orderShipments(orderId) {
  return bcGet(`/orders/${orderId}/shipments`);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order's status_id, date_modified, its shipments, the current time, and a grace period, and returns whether the order is flagged and why. The rule is strict on purpose. Only orders in status_id 2, 3, or 10 are considered, and only once they are older than the grace window, so a 3PL that has not posted tracking for an order shipped an hour ago is not flagged by mistake. An order with zero shipment records gets reason no_shipment_record. An order whose shipments all have empty tracking_number, tracking_link, and shipping_provider gets reason shipment_missing_tracking. Anything else is left alone.

decide.py
from datetime import datetime, timezone, timedelta

SHIPPED_LIKE_STATUSES = {2, 3, 10}  # Shipped, Partially Shipped, Completed

def _is_blank(value):
    return not (value or "").strip()

def find_untracked_shipped_orders(orders, shipments_by_order_id, now, grace_hours=24):
    flagged = []
    for order in orders:
        if order["status_id"] not in SHIPPED_LIKE_STATUSES:
            continue
        modified = order["date_modified"]
        if isinstance(modified, str):
            modified = datetime.fromisoformat(modified.replace("Z", "+00:00"))
        if now - modified < timedelta(hours=grace_hours):
            continue

        shipments = shipments_by_order_id.get(order["id"], [])
        if not shipments:
            flagged.append({"orderId": order["id"], "reason": "no_shipment_record"})
            continue

        all_missing_tracking = all(
            _is_blank(s.get("tracking_number")) and _is_blank(s.get("tracking_link")) and _is_blank(s.get("shipping_provider"))
            for s in shipments
        )
        if all_missing_tracking:
            flagged.append({"orderId": order["id"], "reason": "shipment_missing_tracking"})
    return flagged
decide.js
const SHIPPED_LIKE_STATUSES = new Set([2, 3, 10]); // Shipped, Partially Shipped, Completed

function isBlank(value) {
  return !(value || "").trim();
}

export function findUntrackedShippedOrders(orders, shipmentsByOrderId, now, graceHours = 24) {
  const flagged = [];
  for (const order of orders) {
    if (!SHIPPED_LIKE_STATUSES.has(order.status_id)) continue;

    const modified = new Date(order.date_modified);
    const ageHours = (now.getTime() - modified.getTime()) / 3600000;
    if (ageHours < graceHours) continue;

    const shipments = shipmentsByOrderId.get(order.id) || [];
    if (shipments.length === 0) {
      flagged.push({ orderId: order.id, reason: "no_shipment_record" });
      continue;
    }

    const allMissingTracking = shipments.every(
      (s) => isBlank(s.tracking_number) && isBlank(s.tracking_link) && isBlank(s.shipping_provider)
    );
    if (allMissingTracking) {
      flagged.push({ orderId: order.id, reason: "shipment_missing_tracking" });
    }
  }
  return flagged;
}
5

Flag the order, never fabricate a tracking number

When an order is flagged, the script cannot know the real carrier or tracking number, so it never guesses. It appends a note to staff_notes with PUT /v2/orders/{id}, something like "ALERT: order marked Shipped on {date} with no tracking number, verify with fulfillment," so a person notices the order the next time they open it, and optionally sends a Slack or email alert through your own webhook target. The actual fix, entering the real tracking number, stays a job for a human using POST /v2/orders/{id}/shipments or PUT /v2/orders/{id}/shipments/{shipment_id}.

apply.py
def append_staff_note(order_id, existing_notes, message):
    note = (existing_notes or "").rstrip()
    updated = f"{note}\n{message}".strip() if note else message
    return bc_put(f"/orders/{order_id}", {"staff_notes": updated})
apply.js
async function appendStaffNote(orderId, existingNotes, message) {
  const note = (existingNotes || "").trimEnd();
  const updated = note ? `${note}\n${message}`.trim() : message;
  return bcPut(`/orders/${orderId}`, { staff_notes: updated });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs which orders it would flag, with the order id and the reason. Read the output, agree with it, then switch it off to let it write the staff_notes. Run it on a schedule that matches how often you fulfill orders, for example once a day.

Run it safe

Always start with DRY_RUN=true, and never let this job invent a tracking_number. Its only job is flagging the order so a person enters the real carrier and tracking information themselves.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only flags orders old enough to rule out tracking that simply has not posted yet, and it never writes a fabricated tracking_number.

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

find_untracked_shipped_orders.py
"""Flag BigCommerce orders marked Shipped that never got a real tracking number.

POST /v2/orders/{id}/shipments only requires order_address_id and items.
tracking_number is optional, alongside tracking_link and shipping_provider.
That means the Ship Items modal in the control panel, or a connected OMS or
3PL such as ShipStation, Cin7, or ShipHero, can create a shipment with the
Tracking ID box left blank, or an integration can move status_id straight to
2 (Shipped) with PUT /v2/orders/{id} and skip shipment creation entirely.
Either way, the order looks fulfilled while the customer has no way to track
their package, and the automated shipping confirmation email's tracking link
points nowhere. This job lists orders in status_id 2 (Shipped), 3 (Partially
Shipped), and 10 (Completed), reads each order's shipments, and flags only
the ones older than a grace window that have zero shipment records or whose
shipments all carry empty tracking_number, tracking_link, and
shipping_provider fields. It never fabricates a tracking number, it only
leaves a note for a human to fill in the real one. Run on a schedule. Safe
to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/shipment-tracking-never-added/
"""
import os
import logging
from datetime import datetime, timezone, timedelta

import requests

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

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"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
GRACE_HOURS = int(os.environ.get("GRACE_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

SHIPPED_LIKE_STATUSES = {2, 3, 10}  # Shipped, Partially Shipped, Completed

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()


def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def _is_blank(value):
    return not (value or "").strip()


def find_untracked_shipped_orders(orders, shipments_by_order_id, now, grace_hours=24):
    """Pure decision. No network, no side effects.

    Considers only orders in status_id 2, 3, or 10, and only once date_modified
    is older than grace_hours, so an order the 3PL shipped moments ago is not
    flagged before tracking has had a chance to post. An order with an empty
    shipments list is flagged no_shipment_record. An order whose shipments all
    have empty tracking_number, tracking_link, and shipping_provider is flagged
    shipment_missing_tracking. Anything else is left alone.
    """
    flagged = []
    for order in orders:
        if order["status_id"] not in SHIPPED_LIKE_STATUSES:
            continue

        modified = order["date_modified"]
        if isinstance(modified, str):
            modified = datetime.fromisoformat(modified.replace("Z", "+00:00"))
        if now - modified < timedelta(hours=grace_hours):
            continue

        shipments = shipments_by_order_id.get(order["id"], [])
        if not shipments:
            flagged.append({"orderId": order["id"], "reason": "no_shipment_record"})
            continue

        all_missing_tracking = all(
            _is_blank(s.get("tracking_number")) and _is_blank(s.get("tracking_link")) and _is_blank(s.get("shipping_provider"))
            for s in shipments
        )
        if all_missing_tracking:
            flagged.append({"orderId": order["id"], "reason": "shipment_missing_tracking"})

    return flagged


def shipped_like_orders():
    """Page through orders in status_id 2, 3, and 10 within the lookback window."""
    for status_id in SHIPPED_LIKE_STATUSES:
        page = 1
        while True:
            orders = bc_get(
                "/orders",
                {
                    "status_id": status_id,
                    "min_date_modified": f"-{LOOKBACK_DAYS} days",
                    "page": page,
                    "limit": 250,
                },
            )
            if not orders:
                break
            for order in orders:
                yield order
            page += 1


def order_shipments(order_id):
    return bc_get(f"/orders/{order_id}/shipments")


def append_staff_note(order_id, existing_notes, message):
    note = (existing_notes or "").rstrip()
    updated = f"{note}\n{message}".strip() if note else message
    return bc_put(f"/orders/{order_id}", {"staff_notes": updated})


def run():
    now = datetime.now(timezone.utc)
    orders = list(shipped_like_orders())
    shipments_by_order_id = {order["id"]: order_shipments(order["id"]) for order in orders}

    flagged = find_untracked_shipped_orders(orders, shipments_by_order_id, now, GRACE_HOURS)

    for item in flagged:
        order_id = item["orderId"]
        reason = item["reason"]
        log.warning(
            "order_id=%s reason=%s %s",
            order_id, reason,
            "would flag with staff_notes" if DRY_RUN else "flagging with staff_notes",
        )
        if not DRY_RUN:
            order = next((o for o in orders if o["id"] == order_id), {})
            message = f"ALERT: order marked Shipped on {now.date()} with no tracking number, verify with fulfillment."
            append_staff_note(order_id, order.get("staff_notes"), message)

    log.info("Done. %d order(s) %s.", len(flagged), "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
find-untracked-shipped-orders.js
/**
 * Flag BigCommerce orders marked Shipped that never got a real tracking number.
 *
 * POST /v2/orders/{id}/shipments only requires order_address_id and items.
 * tracking_number is optional, alongside tracking_link and shipping_provider.
 * That means the Ship Items modal in the control panel, or a connected OMS
 * or 3PL such as ShipStation, Cin7, or ShipHero, can create a shipment with
 * the Tracking ID box left blank, or an integration can move status_id
 * straight to 2 (Shipped) with PUT /v2/orders/{id} and skip shipment
 * creation entirely. Either way, the order looks fulfilled while the
 * customer has no way to track their package, and the automated shipping
 * confirmation email's tracking link points nowhere. This job lists orders
 * in status_id 2 (Shipped), 3 (Partially Shipped), and 10 (Completed), reads
 * each order's shipments, and flags only the ones older than a grace window
 * that have zero shipment records or whose shipments all carry empty
 * tracking_number, tracking_link, and shipping_provider fields. It never
 * fabricates a tracking number, it only leaves a note for a human to fill
 * in the real one. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/shipment-tracking-never-added/
 */
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const GRACE_HOURS = Number(process.env.GRACE_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const SHIPPED_LIKE_STATUSES = new Set([2, 3, 10]); // Shipped, Partially Shipped, Completed

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

function isBlank(value) {
  return !(value || "").trim();
}

/**
 * Pure decision. No network, no side effects.
 *
 * Considers only orders in status_id 2, 3, or 10, and only once
 * date_modified is older than graceHours, so an order the 3PL shipped
 * moments ago is not flagged before tracking has had a chance to post. An
 * order with an empty shipments list is flagged no_shipment_record. An
 * order whose shipments all have empty tracking_number, tracking_link, and
 * shipping_provider is flagged shipment_missing_tracking. Anything else is
 * left alone.
 */
export function findUntrackedShippedOrders(orders, shipmentsByOrderId, now, graceHours = 24) {
  const flagged = [];
  for (const order of orders) {
    if (!SHIPPED_LIKE_STATUSES.has(order.status_id)) continue;

    const modified = new Date(order.date_modified);
    const ageHours = (now.getTime() - modified.getTime()) / 3600000;
    if (ageHours < graceHours) continue;

    const shipments = shipmentsByOrderId.get(order.id) || [];
    if (shipments.length === 0) {
      flagged.push({ orderId: order.id, reason: "no_shipment_record" });
      continue;
    }

    const allMissingTracking = shipments.every(
      (s) => isBlank(s.tracking_number) && isBlank(s.tracking_link) && isBlank(s.shipping_provider)
    );
    if (allMissingTracking) {
      flagged.push({ orderId: order.id, reason: "shipment_missing_tracking" });
    }
  }
  return flagged;
}

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 bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* shippedLikeOrders() {
  for (const statusId of SHIPPED_LIKE_STATUSES) {
    let page = 1;
    while (true) {
      const orders = await bcGet("/orders", {
        status_id: statusId,
        min_date_modified: `-${LOOKBACK_DAYS} days`,
        page,
        limit: 250,
      });
      if (!orders.length) break;
      for (const order of orders) yield order;
      page += 1;
    }
  }
}

async function orderShipments(orderId) {
  return bcGet(`/orders/${orderId}/shipments`);
}

async function appendStaffNote(orderId, existingNotes, message) {
  const note = (existingNotes || "").trimEnd();
  const updated = note ? `${note}\n${message}`.trim() : message;
  return bcPut(`/orders/${orderId}`, { staff_notes: updated });
}

export async function run() {
  const now = new Date();
  const orders = [];
  for await (const order of shippedLikeOrders()) orders.push(order);

  const shipmentsByOrderId = new Map();
  for (const order of orders) {
    shipmentsByOrderId.set(order.id, await orderShipments(order.id));
  }

  const flagged = findUntrackedShippedOrders(orders, shipmentsByOrderId, now, GRACE_HOURS);

  for (const item of flagged) {
    console.warn(
      `order_id=${item.orderId} reason=${item.reason} ` +
      `${DRY_RUN ? "would flag with staff_notes" : "flagging with staff_notes"}`
    );
    if (!DRY_RUN) {
      const order = orders.find((o) => o.id === item.orderId) || {};
      const message = `ALERT: order marked Shipped on ${now.toISOString().slice(0, 10)} with no tracking number, verify with fulfillment.`;
      await appendStaffNote(item.orderId, order.staff_notes, message);
    }
  }

  console.log(`Done. ${flagged.length} 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 decision rule is the part most worth testing, because it decides which real orders get flagged for a human to review. Because find_untracked_shipped_orders takes only plain values, a plain clock, and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_shipment_tracking_flag.py
from datetime import datetime, timezone, timedelta

from find_untracked_shipped_orders import find_untracked_shipped_orders

NOW = datetime(2026, 7, 10, tzinfo=timezone.utc)


def order(order_id=1, status_id=2, hours_ago=48):
    return {
        "id": order_id,
        "status_id": status_id,
        "date_modified": (NOW - timedelta(hours=hours_ago)).isoformat(),
    }


def test_flags_order_with_no_shipment_record():
    result = find_untracked_shipped_orders([order()], {}, NOW)
    assert result == [{"orderId": 1, "reason": "no_shipment_record"}]


def test_flags_order_whose_shipment_has_no_tracking():
    shipments = {1: [{"tracking_number": "", "tracking_link": "", "shipping_provider": ""}]}
    result = find_untracked_shipped_orders([order()], shipments, NOW)
    assert result == [{"orderId": 1, "reason": "shipment_missing_tracking"}]


def test_does_not_flag_order_with_real_tracking():
    shipments = {1: [{"tracking_number": "1Z999", "tracking_link": "", "shipping_provider": "ups"}]}
    result = find_untracked_shipped_orders([order()], shipments, NOW)
    assert result == []


def test_does_not_flag_within_grace_window():
    result = find_untracked_shipped_orders([order(hours_ago=2)], {}, NOW, grace_hours=24)
    assert result == []


def test_ignores_orders_not_in_shipped_like_statuses():
    result = find_untracked_shipped_orders([order(status_id=11)], {}, NOW)
    assert result == []


def test_flags_partially_shipped_and_completed_too():
    orders = [order(order_id=2, status_id=3), order(order_id=3, status_id=10)]
    result = find_untracked_shipped_orders(orders, {}, NOW)
    assert {r["orderId"] for r in result} == {2, 3}


def test_one_shipment_with_tracking_clears_the_order_even_if_another_lacks_it():
    shipments = {
        1: [
            {"tracking_number": "", "tracking_link": "", "shipping_provider": ""},
            {"tracking_number": "1Z999", "tracking_link": "", "shipping_provider": "ups"},
        ]
    }
    result = find_untracked_shipped_orders([order()], shipments, NOW)
    assert result == []
find-untracked-shipped-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findUntrackedShippedOrders } from "./find-untracked-shipped-orders.js";

const NOW = new Date("2026-07-10T00:00:00Z");

function order({ orderId = 1, statusId = 2, hoursAgo = 48 } = {}) {
  return {
    id: orderId,
    status_id: statusId,
    date_modified: new Date(NOW.getTime() - hoursAgo * 3600000).toISOString(),
  };
}

test("flags order with no shipment record", () => {
  const result = findUntrackedShippedOrders([order()], new Map(), NOW);
  assert.deepEqual(result, [{ orderId: 1, reason: "no_shipment_record" }]);
});

test("flags order whose shipment has no tracking", () => {
  const shipments = new Map([[1, [{ tracking_number: "", tracking_link: "", shipping_provider: "" }]]]);
  const result = findUntrackedShippedOrders([order()], shipments, NOW);
  assert.deepEqual(result, [{ orderId: 1, reason: "shipment_missing_tracking" }]);
});

test("does not flag order with real tracking", () => {
  const shipments = new Map([[1, [{ tracking_number: "1Z999", tracking_link: "", shipping_provider: "ups" }]]]);
  const result = findUntrackedShippedOrders([order()], shipments, NOW);
  assert.deepEqual(result, []);
});

test("does not flag within grace window", () => {
  const result = findUntrackedShippedOrders([order({ hoursAgo: 2 })], new Map(), NOW, 24);
  assert.deepEqual(result, []);
});

test("ignores orders not in shipped-like statuses", () => {
  const result = findUntrackedShippedOrders([order({ statusId: 11 })], new Map(), NOW);
  assert.deepEqual(result, []);
});

test("flags partially shipped and completed too", () => {
  const orders = [order({ orderId: 2, statusId: 3 }), order({ orderId: 3, statusId: 10 })];
  const result = findUntrackedShippedOrders(orders, new Map(), NOW);
  assert.deepEqual(new Set(result.map((r) => r.orderId)), new Set([2, 3]));
});

test("one shipment with tracking clears the order even if another lacks it", () => {
  const shipments = new Map([
    [
      1,
      [
        { tracking_number: "", tracking_link: "", shipping_provider: "" },
        { tracking_number: "1Z999", tracking_link: "", shipping_provider: "ups" },
      ],
    ],
  ]);
  const result = findUntrackedShippedOrders([order()], shipments, NOW);
  assert.deepEqual(result, []);
});

Case studies

Ship Items modal

The store where staff shipped in a rush and skipped the field

A busy fulfillment team packed dozens of orders a day and used the Ship Items modal to close them out fast. Under pressure, staff sometimes clicked Create shipment before the courier's tracking number had synced from the label printer, leaving the Tracking ID box empty, and BigCommerce accepted it without complaint.

A daily job now catches every order that crossed the grace window with an empty shipment record. Staff get a short list each morning instead of finding out from a customer email days later, and the confirmation email's tracking link is fixed before most customers ever notice it was broken.

OMS integration

The 3PL integration that shipped orders without ever calling the shipment endpoint

A store's order management system moved orders straight to status_id 2 with a bulk status update whenever its warehouse marked a pick complete, without ever calling POST /v2/orders/{id}/shipments. Every order looked Shipped in BigCommerce, but not one of them had a shipment record behind it.

Running the script in dry run first showed the entire pattern immediately, hundreds of orders with reason no_shipment_record. The team fixed the OMS integration going forward and used the flagged list to backfill tracking on the existing backlog by hand.

What good looks like

After this runs on a schedule, an order that reaches Shipped, Partially Shipped, or Completed without real tracking information gets caught within a day instead of surfacing as a support ticket. Orders with a genuine tracking_number, tracking_link, or shipping_provider are left exactly alone, and nothing is ever guessed on the store's behalf. The gap between "the order says Shipped" and "the customer can actually track it" closes fast, and it stays closed.

FAQ

Why can a BigCommerce order be marked Shipped with no tracking number at all?

Because tracking_number is an optional field on the shipment resource. POST /v2/orders/{id}/shipments only requires order_address_id and items. A merchant can click Create shipment in the Ship Items modal, or a connected OMS or 3PL can create the shipment, with the Tracking ID field left blank, and BigCommerce will accept it. The order can also reach status_id 2, Shipped, through a plain PUT /v2/orders/{id} status change with no shipment record created at all.

Is status_id 2, Shipped, proof that a customer can actually track their package?

No. status_id only tells you the merchant says the order shipped. The real proof is in GET /v2/orders/{id}/shipments, where a genuinely fulfilled order should have at least one shipment record with a non-empty tracking_number or a custom tracking_link. An order can be Shipped, Partially Shipped, or even Completed with zero shipment records or with shipment records that carry no tracking information at all.

Can a script safely fill in the missing tracking number automatically?

No, and it should not try. BigCommerce has no way to know which carrier or tracking number was supposed to be entered, so fabricating one would be worse than leaving it blank. The safe pattern is to flag the order, for example by appending a note to staff_notes with PUT /v2/orders/{id} or alerting a channel, so a person enters the real tracking number with POST /v2/orders/{id}/shipments or PUT /v2/orders/{id}/shipments/{shipment_id}.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Docs: List Order Shipments. docs.bigcommerce.com get-order-shipments
  2. BigCommerce Help Center: Shipments. support.bigcommerce.com/s/article/Shipments
  3. BigCommerce Community Q&A: Creating shipments, shipment tracking number. support.bigcommerce.com creating-shipments-shipment-tracking-number

On the solution:

  1. BigCommerce Developer Docs: Create Order Shipment (REST Management). docs.bigcommerce.com create-order-shipments
  2. BigCommerce Developer Docs: Get Order Shipment (REST Management). docs.bigcommerce.com get-order-shipment
  3. BigCommerce Developer Docs: List Orders (REST Management). docs.bigcommerce.com get-orders

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 catch an order you would have missed?

If this saved you from a customer support ticket about a tracking link that went nowhere, 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