Diagnostic Fulfillment and shipping

Orders stuck Awaiting Shipment past SLA

The order was paid days ago. It moved to Awaiting Shipment right on schedule. Then nothing. No shipment, no update, no alert, until a customer writes in asking where their package is. BigCommerce never put a clock on that status, so an order can sit there forever if a warehouse task is missed or the system that was supposed to notice has gone quiet. Here is why that silence is allowed to happen and a small script that finds the orders already past your shipping promise.

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

BigCommerce moves a paid order into status_id 11, Awaiting Fulfillment, once payment captures, and your team or OMS moves it to status_id 9, Awaiting Shipment, once it is picked and packed. Neither status has a built-in aging alert, so the order only moves on when someone explicitly posts a shipment. Run a small Python or Node.js script that lists orders in status_id 9, 11, and 8 older than your SLA threshold with GET /v2/orders, confirms each one's payment actually settled with GET /v2/orders/{id}/transactions, confirms no shipment already exists with GET /v2/orders/{id}/shipments, and flags the genuinely overdue ones by appending a note to staff_notes with PUT /v2/orders/{id}. Full code, tests, and a dry run guard are below.

The problem in plain words

When a customer's payment captures, BigCommerce automatically moves the order to status_id 11, Awaiting Fulfillment. From there, your staff, your OMS, or a connected 3PL picks and packs the order and moves it to status_id 9, Awaiting Shipment, once it is ready to go out the door.

That is where BigCommerce's job ends. There is no clock counting how long an order has sat in Awaiting Shipment, and no built-in alert that fires when it crosses a promised ship-by date. The order only leaves that status when a shipment is actually posted. If the person who was supposed to print the label gets pulled onto something else, if a 3PL sync silently fails, or if the webhook that would have told an external fulfillment system about the order has quietly died, the order just waits. It looks fine in a status filter. It is not fine at all.

Payment captures status_id 11 Picked and packed status_id 9 no SLA clock, webhook died Awaiting Shipment aging silently Customer complains first
Nothing is broken in a way BigCommerce can see. The order simply never leaves Awaiting Shipment until someone posts a shipment, and nothing is watching the clock.

Why it happens

BigCommerce's order statuses describe a state, not a promise. A few common ways paid orders end up aging past a shipping SLA with nobody noticing:

The result is paid, unshipped orders that pile up invisibly until a customer asks where their package is. See the citations at the end for the exact API docs and webhook behavior.

The key insight

status_id 9 or 11 only tells you where an order is in the workflow, not how long it has been there or whether it is actually overdue. The real signal is age combined with proof that the order is genuinely unshipped. So the safe pattern is not "alert on every Awaiting Shipment order." It is "compute how old the order really is, confirm the payment settled with GET /v2/orders/{id}/transactions, confirm no shipment already exists with GET /v2/orders/{id}/shipments, and only then compare the age against the SLA." That keeps the alert list honest instead of noisy.

The fix, as a flow

We do not guess that an order shipped and we never touch its status. We add a job that lists candidate orders, confirms each one is truly paid and truly unshipped, computes how many hours past the SLA it is, and leaves a note for a human on the ones that are genuinely overdue. Everything within the SLA, or already shipped, is left alone.

Scheduled job runs on a timer List candidate orders status_id 9, 11, 8 Confirm paid, unshipped transactions, shipments Older than the SLA? yes no, leave alone Flag for a human staff_notes alert
The script only flags orders that are confirmed paid, confirmed unshipped, and genuinely past the promised SLA. Every write is a staff-only note, never a status change.

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 transactions and 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 SLA_HOURS="48"
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 SLA_HOURS="48"
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 transactions and 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 candidate orders and confirm they are truly paid and unshipped

Call GET /v2/orders?status_id=9&limit=250, paginated, and repeat for status_id=11 (Awaiting Fulfillment) and status_id=8 (Awaiting Pickup), since orders can age in any of these staging statuses. Use min_date_created and max_date_created in RFC-2822 format to narrow the window if the backlog is large. For each candidate, call GET /v2/orders/{id}/transactions and look for a captured or settled transaction rather than only an authorization, and call GET /v2/orders/{id}/shipments where an empty array means the order really has not shipped.

step3.py
TARGET_STATUSES = (9, 11, 8)  # Awaiting Shipment, Awaiting Fulfillment, Awaiting Pickup
SETTLED_TRANSACTION_TYPES = {"capture", "settled", "sale"}

def candidate_orders():
    for status_id in TARGET_STATUSES:
        page = 1
        while True:
            orders = bc_get("/orders", {"status_id": status_id, "page": page, "limit": 250})
            if not orders:
                break
            for order in orders:
                yield order
            page += 1

def order_transactions(order_id):
    return bc_get(f"/orders/{order_id}/transactions")

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

def has_captured_payment(transactions):
    return any((t.get("type") or "").lower() in SETTLED_TRANSACTION_TYPES for t in transactions or [])
step3.js
const TARGET_STATUSES = [9, 11, 8]; // Awaiting Shipment, Awaiting Fulfillment, Awaiting Pickup
const SETTLED_TRANSACTION_TYPES = new Set(["capture", "settled", "sale"]);

async function* candidateOrders() {
  for (const statusId of TARGET_STATUSES) {
    let page = 1;
    while (true) {
      const orders = await bcGet("/orders", { status_id: statusId, page, limit: 250 });
      if (!orders.length) break;
      for (const order of orders) yield order;
      page += 1;
    }
  }
}

async function orderTransactions(orderId) {
  return bcGet(`/orders/${orderId}/transactions`);
}

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

function hasCapturedPayment(transactions) {
  return (transactions || []).some((t) => SETTLED_TRANSACTION_TYPES.has((t.type || "").toLowerCase()));
}
4

Decide, with one pure function

Keep the decision in its own function that takes plain order records already enriched with has_shipment and payment_status, the current time, and the SLA in hours, and returns the overdue set. The rule is strict on purpose. Only orders in status_id 9 or 11 by default are considered, orders that already have a shipment are excluded since the status just has not synced yet, and orders whose payment is not captured are excluded too, which guards against Awaiting Payment or a declined order masquerading under the wrong status_id. Age is computed from date_created, and only orders past the SLA are kept, sorted worst breach first.

decide.py
from datetime import datetime

TARGET_STATUS_IDS = {9, 11}

def find_overdue_orders(orders, now, sla_hours, target_status_ids=TARGET_STATUS_IDS):
    overdue = []
    for order in orders:
        if order["status_id"] not in target_status_ids:
            continue
        if order.get("has_shipment"):
            continue
        if order.get("payment_status") != "captured":
            continue

        created = order["date_created"]
        if isinstance(created, str):
            created = datetime.fromisoformat(created.replace("Z", "+00:00"))
        age_hours = (now - created).total_seconds() / 3600

        if age_hours > sla_hours:
            overdue.append({
                "order_id": order["id"],
                "status_id": order["status_id"],
                "date_created": created,
                "age_hours": age_hours,
                "overage_hours": age_hours - sla_hours,
            })

    overdue.sort(key=lambda o: o["overage_hours"], reverse=True)
    return overdue
decide.js
const TARGET_STATUS_IDS = new Set([9, 11]);

export function findOverdueOrders(orders, now, slaHours, targetStatusIds = TARGET_STATUS_IDS) {
  const overdue = [];
  for (const order of orders) {
    if (!targetStatusIds.has(order.status_id)) continue;
    if (order.has_shipment) continue;
    if (order.payment_status !== "captured") continue;

    const created = new Date(order.date_created);
    const ageHours = (now.getTime() - created.getTime()) / 3600000;

    if (ageHours > slaHours) {
      overdue.push({
        orderId: order.id,
        statusId: order.status_id,
        dateCreated: created,
        ageHours,
        overageHours: ageHours - slaHours,
      });
    }
  }

  overdue.sort((a, b) => b.overageHours - a.overageHours);
  return overdue;
}
5

Flag the order, never mark it shipped

When an order is overdue, the script cannot know whether it truly shipped and just did not sync, so it never marks it shipped or creates a fake shipment record. That would falsify fulfillment data and could trigger a customer notification about a package that never left. Instead it appends a note to staff_notes with PUT /v2/orders/{id}, which is control-panel-only and never customer-visible, so it is a safe, reversible write.

apply.py
def append_sla_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 appendSlaNote(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 the order ids and ages it would tag. Read the output, agree with it, then switch it off to let it write the staff_notes and, if it finds one deactivated, reactivate the store/order/statusUpdated webhook through /v3/hooks so your fulfillment system starts seeing these orders again. Run it on a schedule that matches your shipping promise, for example every few hours.

Run it safe

Always start with DRY_RUN=true, and never let this job mark an order shipped or create a shipment record on its own. Its only job is flagging the order so a person checks the warehouse, the 3PL sync, or the webhook, and ships or investigates it for real.

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 that are confirmed paid, confirmed unshipped, and genuinely past the SLA, and it never writes anything but a staff-only note.

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

find_overdue_awaiting_shipment.py
"""Flag BigCommerce orders stuck Awaiting Shipment past a shipping SLA.

BigCommerce moves a paid order into status_id 11 (Awaiting Fulfillment)
automatically once payment captures, and merchants or OMS integrations move
it to status_id 9 (Awaiting Shipment) once picked and packed. Neither status
has a built-in SLA clock or aging alert, so an order only leaves Awaiting
Shipment when someone explicitly posts a shipment. Orders age silently past
a shipping promise whenever a warehouse task is missed, a 3PL or OMS sync
fails, or the store/order/statusUpdated webhook that would have notified an
external fulfillment system was auto-deactivated by BigCommerce after
repeated non-2xx responses and never recreated. This job lists orders in
status_id 9, 11, and 8, confirms each candidate's payment actually settled
and that no shipment already exists, computes how far past the SLA it is,
and flags only the genuinely overdue ones with a note on staff_notes. It
never marks an order shipped and never fabricates a shipment record. Run on
a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/orders-stuck-awaiting-shipment-past-sla/
"""
import os
import logging
from datetime import datetime, timezone

import requests

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

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"
SLA_HOURS = float(os.environ.get("SLA_HOURS", "48"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CANDIDATE_STATUSES = (9, 11, 8)  # Awaiting Shipment, Awaiting Fulfillment, Awaiting Pickup
TARGET_STATUS_IDS = {9, 11}
SETTLED_TRANSACTION_TYPES = {"capture", "settled", "sale"}

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 has_captured_payment(transactions):
    return any((t.get("type") or "").lower() in SETTLED_TRANSACTION_TYPES for t in transactions or [])


def find_overdue_orders(orders, now, sla_hours, target_status_ids=None):
    """Pure decision. No network, no side effects.

    Filters orders down to status_id 9 or 11 (or a caller-supplied set),
    excludes any order that already has a shipment (the status just has not
    synced yet) or whose payment is not captured (guards against Awaiting
    Payment or a declined order masquerading under the wrong status_id),
    computes age_hours from date_created, and keeps only the orders older
    than sla_hours. Returns overdue orders sorted by overage_hours
    descending, so the worst breaches surface first.
    """
    if target_status_ids is None:
        target_status_ids = TARGET_STATUS_IDS

    overdue = []
    for order in orders:
        if order["status_id"] not in target_status_ids:
            continue
        if order.get("has_shipment"):
            continue
        if order.get("payment_status") != "captured":
            continue

        created = order["date_created"]
        if isinstance(created, str):
            created = datetime.fromisoformat(created.replace("Z", "+00:00"))
        age_hours = (now - created).total_seconds() / 3600

        if age_hours > sla_hours:
            overdue.append({
                "order_id": order["id"],
                "status_id": order["status_id"],
                "date_created": created,
                "age_hours": age_hours,
                "overage_hours": age_hours - sla_hours,
            })

    overdue.sort(key=lambda o: o["overage_hours"], reverse=True)
    return overdue


def candidate_orders():
    """Page through orders in the staging statuses that can age past an SLA."""
    for status_id in CANDIDATE_STATUSES:
        page = 1
        while True:
            orders = bc_get("/orders", {"status_id": status_id, "page": page, "limit": 250})
            if not orders:
                break
            for order in orders:
                yield order
            page += 1


def order_transactions(order_id):
    return bc_get(f"/orders/{order_id}/transactions")


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


def append_sla_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(candidate_orders())

    enriched = []
    for order in orders:
        transactions = order_transactions(order["id"])
        shipments = order_shipments(order["id"])
        enriched.append({
            **order,
            "payment_status": "captured" if has_captured_payment(transactions) else "uncaptured",
            "has_shipment": len(shipments) > 0,
        })

    overdue = find_overdue_orders(enriched, now, SLA_HOURS)

    for item in overdue:
        message = (
            f"[SLA-ALERT] Awaiting Shipment since {item['date_created']}, "
            f"{item['age_hours']:.1f}h over the {SLA_HOURS:.0f}h promise "
            f"- flagged {now.isoformat()}"
        )
        log.warning(
            "order_id=%s age_hours=%.1f overage_hours=%.1f %s",
            item["order_id"], item["age_hours"], item["overage_hours"],
            "would tag" if DRY_RUN else "tagging",
        )
        if not DRY_RUN:
            order = next((o for o in orders if o["id"] == item["order_id"]), {})
            append_sla_note(item["order_id"], order.get("staff_notes"), message)

    log.info("Done. %d order(s) %s.", len(overdue), "to tag" if DRY_RUN else "tagged")


if __name__ == "__main__":
    run()
find-overdue-awaiting-shipment.js
/**
 * Flag BigCommerce orders stuck Awaiting Shipment past a shipping SLA.
 *
 * BigCommerce moves a paid order into status_id 11 (Awaiting Fulfillment)
 * automatically once payment captures, and merchants or OMS integrations
 * move it to status_id 9 (Awaiting Shipment) once picked and packed.
 * Neither status has a built-in SLA clock or aging alert, so an order only
 * leaves Awaiting Shipment when someone explicitly posts a shipment. Orders
 * age silently past a shipping promise whenever a warehouse task is missed,
 * a 3PL or OMS sync fails, or the store/order/statusUpdated webhook that
 * would have notified an external fulfillment system was auto-deactivated
 * by BigCommerce after repeated non-2xx responses and never recreated. This
 * job lists orders in status_id 9, 11, and 8, confirms each candidate's
 * payment actually settled and that no shipment already exists, computes
 * how far past the SLA it is, and flags only the genuinely overdue ones
 * with a note on staff_notes. It never marks an order shipped and never
 * fabricates a shipment record. Run on a schedule. Safe to run again and
 * again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/orders-stuck-awaiting-shipment-past-sla/
 */
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 SLA_HOURS = Number(process.env.SLA_HOURS || 48);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CANDIDATE_STATUSES = [9, 11, 8]; // Awaiting Shipment, Awaiting Fulfillment, Awaiting Pickup
const TARGET_STATUS_IDS = new Set([9, 11]);
const SETTLED_TRANSACTION_TYPES = new Set(["capture", "settled", "sale"]);

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

export function hasCapturedPayment(transactions) {
  return (transactions || []).some((t) => SETTLED_TRANSACTION_TYPES.has((t.type || "").toLowerCase()));
}

/**
 * Pure decision. No network, no side effects.
 *
 * Filters orders down to status_id 9 or 11 (or a caller-supplied set),
 * excludes any order that already has a shipment (the status just has not
 * synced yet) or whose payment is not captured (guards against Awaiting
 * Payment or a declined order masquerading under the wrong status_id),
 * computes ageHours from date_created, and keeps only the orders older
 * than slaHours. Returns overdue orders sorted by overageHours descending,
 * so the worst breaches surface first.
 */
export function findOverdueOrders(orders, now, slaHours, targetStatusIds = TARGET_STATUS_IDS) {
  const overdue = [];
  for (const order of orders) {
    if (!targetStatusIds.has(order.status_id)) continue;
    if (order.has_shipment) continue;
    if (order.payment_status !== "captured") continue;

    const created = new Date(order.date_created);
    const ageHours = (now.getTime() - created.getTime()) / 3600000;

    if (ageHours > slaHours) {
      overdue.push({
        orderId: order.id,
        statusId: order.status_id,
        dateCreated: created,
        ageHours,
        overageHours: ageHours - slaHours,
      });
    }
  }

  overdue.sort((a, b) => b.overageHours - a.overageHours);
  return overdue;
}

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* candidateOrders() {
  for (const statusId of CANDIDATE_STATUSES) {
    let page = 1;
    while (true) {
      const orders = await bcGet("/orders", { status_id: statusId, page, limit: 250 });
      if (!orders.length) break;
      for (const order of orders) yield order;
      page += 1;
    }
  }
}

async function orderTransactions(orderId) {
  return bcGet(`/orders/${orderId}/transactions`);
}

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

async function appendSlaNote(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 candidateOrders()) orders.push(order);

  const enriched = [];
  for (const order of orders) {
    const transactions = await orderTransactions(order.id);
    const shipments = await orderShipments(order.id);
    enriched.push({
      ...order,
      payment_status: hasCapturedPayment(transactions) ? "captured" : "uncaptured",
      has_shipment: shipments.length > 0,
    });
  }

  const overdue = findOverdueOrders(enriched, now, SLA_HOURS);

  for (const item of overdue) {
    const message =
      `[SLA-ALERT] Awaiting Shipment since ${item.dateCreated.toISOString()}, ` +
      `${item.ageHours.toFixed(1)}h over the ${SLA_HOURS}h promise ` +
      `- flagged ${now.toISOString()}`;
    console.warn(
      `order_id=${item.orderId} age_hours=${item.ageHours.toFixed(1)} ` +
      `overage_hours=${item.overageHours.toFixed(1)} ${DRY_RUN ? "would tag" : "tagging"}`
    );
    if (!DRY_RUN) {
      const order = orders.find((o) => o.id === item.orderId) || {};
      await appendSlaNote(item.orderId, order.staff_notes, message);
    }
  }

  console.log(`Done. ${overdue.length} order(s) ${DRY_RUN ? "to tag" : "tagged"}.`);
}

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 chase down. Because find_overdue_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_awaiting_shipment_sla.py
from datetime import datetime, timezone, timedelta

from find_overdue_awaiting_shipment import find_overdue_orders

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


def order(order_id=1, status_id=9, hours_ago=72, has_shipment=False, payment_status="captured"):
    return {
        "id": order_id,
        "status_id": status_id,
        "date_created": (NOW - timedelta(hours=hours_ago)).isoformat(),
        "has_shipment": has_shipment,
        "payment_status": payment_status,
    }


def test_flags_order_past_the_sla():
    result = find_overdue_orders([order(hours_ago=72)], NOW, sla_hours=48)
    assert len(result) == 1
    assert result[0]["order_id"] == 1
    assert result[0]["overage_hours"] == 24


def test_exactly_at_threshold_is_not_overdue():
    result = find_overdue_orders([order(hours_ago=48)], NOW, sla_hours=48)
    assert result == []


def test_already_shipped_but_stale_status_is_excluded():
    result = find_overdue_orders([order(hours_ago=200, has_shipment=True)], NOW, sla_hours=48)
    assert result == []


def test_unpaid_but_wrong_status_id_is_excluded():
    result = find_overdue_orders([order(hours_ago=200, payment_status="uncaptured")], NOW, sla_hours=48)
    assert result == []


def test_multi_status_id_inputs_both_kept():
    orders = [
        order(order_id=1, status_id=9, hours_ago=100),
        order(order_id=2, status_id=11, hours_ago=60),
    ]
    result = find_overdue_orders(orders, NOW, sla_hours=48)
    assert {r["order_id"] for r in result} == {1, 2}


def test_ignores_status_ids_outside_the_target_set():
    result = find_overdue_orders([order(status_id=8, hours_ago=200)], NOW, sla_hours=48)
    assert result == []


def test_sorted_worst_breach_first():
    orders = [
        order(order_id=1, hours_ago=60),   # 12h over
        order(order_id=2, hours_ago=120),  # 72h over
        order(order_id=3, hours_ago=90),   # 42h over
    ]
    result = find_overdue_orders(orders, NOW, sla_hours=48)
    assert [r["order_id"] for r in result] == [2, 3, 1]
find-overdue-awaiting-shipment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOverdueOrders } from "./find-overdue-awaiting-shipment.js";

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

function order({ orderId = 1, statusId = 9, hoursAgo = 72, hasShipment = false, paymentStatus = "captured" } = {}) {
  return {
    id: orderId,
    status_id: statusId,
    date_created: new Date(NOW.getTime() - hoursAgo * 3600000).toISOString(),
    has_shipment: hasShipment,
    payment_status: paymentStatus,
  };
}

test("flags order past the SLA", () => {
  const result = findOverdueOrders([order({ hoursAgo: 72 })], NOW, 48);
  assert.equal(result.length, 1);
  assert.equal(result[0].orderId, 1);
  assert.equal(result[0].overageHours, 24);
});

test("exactly at threshold is not overdue", () => {
  const result = findOverdueOrders([order({ hoursAgo: 48 })], NOW, 48);
  assert.deepEqual(result, []);
});

test("already shipped but stale status is excluded", () => {
  const result = findOverdueOrders([order({ hoursAgo: 200, hasShipment: true })], NOW, 48);
  assert.deepEqual(result, []);
});

test("unpaid but wrong status_id is excluded", () => {
  const result = findOverdueOrders([order({ hoursAgo: 200, paymentStatus: "uncaptured" })], NOW, 48);
  assert.deepEqual(result, []);
});

test("multi status_id inputs both kept", () => {
  const orders = [
    order({ orderId: 1, statusId: 9, hoursAgo: 100 }),
    order({ orderId: 2, statusId: 11, hoursAgo: 60 }),
  ];
  const result = findOverdueOrders(orders, NOW, 48);
  assert.deepEqual(new Set(result.map((r) => r.orderId)), new Set([1, 2]));
});

test("ignores status_ids outside the target set", () => {
  const result = findOverdueOrders([order({ statusId: 8, hoursAgo: 200 })], NOW, 48);
  assert.deepEqual(result, []);
});

test("sorted worst breach first", () => {
  const orders = [
    order({ orderId: 1, hoursAgo: 60 }),  // 12h over
    order({ orderId: 2, hoursAgo: 120 }), // 72h over
    order({ orderId: 3, hoursAgo: 90 }),  // 42h over
  ];
  const result = findOverdueOrders(orders, NOW, 48);
  assert.deepEqual(result.map((r) => r.orderId), [2, 3, 1]);
});

Case studies

Webhook silently deactivated

The store whose OMS stopped hearing about new orders

A mid-size store had an OMS wired to store/order/statusUpdated to pull every order that reached Awaiting Fulfillment. A deploy on the OMS side briefly returned 500s to every webhook delivery, and BigCommerce auto-deactivated the subscription after the failure threshold. Nobody noticed, because the OMS side looked healthy. Orders kept arriving in BigCommerce and kept sitting in Awaiting Shipment, invisible to the system meant to route them.

Running the script in dry run surfaced a growing list of overdue orders within a day, all past the 48 hour SLA. The team recreated the webhook through /v3/hooks, cleared the backlog by hand using the flagged list, and now the job runs every few hours as a backstop even while the webhook is healthy.

Warehouse task missed

The pallet that got shuffled to the back of the queue

A single high volume warehouse shift missed a batch of orders during a staffing gap, and those orders sat picked but never packed and shipped for nearly a week. Nothing in the order list flagged them as different from any other Awaiting Shipment order, and the team only found out when three customers asked for a refund in the same afternoon.

After adding the daily SLA check, the same kind of gap now shows up as a short list of order ids and exact overage hours the very next morning, well before it becomes a support escalation, and staff_notes gives the warehouse lead a paper trail of exactly when each one was flagged.

What good looks like

After this runs on a schedule, an order that crosses the shipping SLA gets caught within hours instead of surfacing as a "where is my order" ticket. Orders that already have a real shipment, or whose payment never actually captured, are left exactly alone, and nothing is ever marked shipped on the store's behalf. The gap between "the order says Awaiting Shipment" and "someone is actually watching how long it has been there" closes fast, and it stays closed.

FAQ

Why does a paid BigCommerce order just sit in Awaiting Shipment?

Because BigCommerce has no built-in SLA clock or aging alert on status_id 9, Awaiting Shipment, or status_id 11, Awaiting Fulfillment. An order only leaves Awaiting Shipment when someone explicitly posts a shipment. If a warehouse task is missed, a 3PL or OMS sync fails, or the store/order/statusUpdated webhook that would have told an external system was auto-deactivated after repeated non-2xx responses and never recreated, the order ages silently until a customer complains.

How do I find orders that are really overdue, not just still processing?

List orders with GET /v2/orders filtered by status_id 9, 11, and 8, using min_date_created and max_date_created to find candidates older than your SLA. For each one, confirm the payment actually settled with GET /v2/orders/{id}/transactions, and confirm no shipment already exists with GET /v2/orders/{id}/shipments, since an empty array means the order is truly unshipped despite its status. That combination separates a real breach from an order whose status simply has not synced yet.

Is it safe to have a script automatically mark these orders shipped?

No. Marking an order shipped or creating a fake shipment record would falsify fulfillment data and can trigger a customer notification about a package that was never sent. The safe corrective action is to flag the overdue order for human review by appending a note to staff_notes with PUT /v2/orders/{id}, which is control-panel-only and never customer-visible, and optionally reactivate a dead store/order/statusUpdated webhook so the fulfillment team sees future orders again.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Order Status. developer.bigcommerce.com/docs/rest-management/orders/order-status
  2. BigCommerce Help Center: Order Statuses. support.bigcommerce.com/s/article/Order-Statuses
  3. BigCommerce Developer Center: Webhooks Overview. developer.bigcommerce.com/docs/integrations/webhooks

On the solution:

  1. BigCommerce Developer Center: Orders (REST Management). developer.bigcommerce.com/docs/rest-management/orders
  2. BigCommerce Developer Docs: List Order Shipments. developer.bigcommerce.com/docs/rest-management/orders/order-shipments
  3. BigCommerce Docs: Update Order (REST Management). docs.bigcommerce.com update-order

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 package that never shipped, 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