Diagnostic Orders
Order missing shipping address when no line item is flagged physical
You audit an order, call GET /v2/orders/{id}/shipping_addresses, and get back an empty array. Before you file that as a bug, check what was actually in the cart. BigCommerce only writes a shipping_addresses record when the checkout contained at least one physical line item. An order made up entirely of downloads, services, or gift certificates never gets one, and that is correct. The real anomaly is a physical item with no address on file. Here is how to tell the two apart and a script that flags only the genuine problem.
BigCommerce only generates a shipping_addresses record on an order if the cart that produced it held at least one line item whose product type is physical. If every line item is digital, downloads, services, or gift certificates, checkout never prompts for a ship-to address, so GET /v2/orders/{id}/shipping_addresses legitimately returns an empty array. That is expected platform behavior, not a bug. The real problem is downstream: order-management, WMS, and reporting tools that assume every order has exactly one shipping address will misclassify digital-only orders as broken, or worse, wave through a genuinely broken order because it "looks like" the digital-only case. The fix is a consistency check, run a small Python or Node.js script that resolves each line item's product type, cross-references it against the shipping_addresses array and the order's status_id, and flags only orders where a physical item shipped with no address, an anomaly_missing_address, while treating an all-digital order with no address as ok_digital_only. Full code, tests, and a dry run guard are below.
The problem in plain words
An order's shipping_addresses record is not a default field that every order gets. It only exists because checkout collected it, and checkout only collects a ship-to address when something in the cart actually needs to be shipped. A cart made entirely of digital downloads, subscriptions billed as services, or gift certificates has nothing to ship, so BigCommerce never prompts for an address and never writes one to the order.
That is fine on its own. The trouble starts when something else in your stack, an order-management system, a WMS or 3PL sync job, or an internal report, assumes "every order has exactly one shipping address" as an invariant. Feed it a digital-only order and it sees an empty shipping_addresses array and flags the order as broken, even though nothing is wrong. Worse, the same blind assumption can hide a real problem: an order created through a custom or headless checkout that called the orders API directly, bypassing the standard cart and consignment flow, can end up with a physical line item and no shipping address at all. If your monitoring treats every empty array the same way, that order gets waved through as "just another digital order" when it is actually broken.
Why it happens
The empty array is a side effect of how BigCommerce checkout works, not a data-integrity bug. A few things line up to cause it:
- Every product in the store has a
typefield,physicalordigital, set in the catalog. Downloads, services, and gift certificates are digital. Checkout only asks for a shipping address when the cart's contents require one. - An order made up of 100% digital line items never triggers that prompt, so BigCommerce never persists a shipping_addresses record for it.
GET /v2/orders/{id}/shipping_addressesreturns an empty array by design, forever, for that order. - A custom or headless checkout integration that creates orders directly through the API, instead of going through the standard cart and checkout flow, can skip submitting consignments entirely. That produces an order with a physical line item and no shipping address, a genuine anomaly that looks identical to the digital-only case if you only check "is the array empty."
- Order-management, WMS/3PL sync, and reporting tools built on the assumption "every order has exactly one shipping address" were not written with the digital-only case in mind, so they alert on orders that are actually fine, and can bury the ones that are not.
See the citations at the end for the BigCommerce support threads where merchants ran into this and the developer docs on order shipping addresses and digital products.
An empty shipping_addresses array is not itself evidence of a problem. It is only meaningful next to the order's line items. So the check is not "does this order have a shipping address." It is "does this order have at least one physical line item, and if so, does it have a shipping address." Resolve each line item's product_id to its catalog type with GET /v3/catalog/products/{product_id}, cache it since many orders share SKUs, and only treat the empty array as an anomaly when a physical item is present and the order's status_id is a real post-checkout state.
The fix, as a flow
We do not change checkout or invent addresses. We add an audit job that walks a set of orders, resolves what each order actually contains, and classifies each one so only the true anomalies get surfaced.
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 reuse the store's existing app credentials. Grant it Orders (read) and Products (read) scope, since this audit only reads data, it never writes a shipping address. 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 LOOKBACK_DAYS="14"
export DRY_RUN="true" # start safe, change to false to write staff_notes
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true" // start safe, change to false to write staff_notes
Talk to the V2 Orders API and the V3 Catalog API
Order data lives at https://api.bigcommerce.com/stores/{store_hash}/v2/, and product catalog data lives at https://api.bigcommerce.com/stores/{store_hash}/v3/. Both use the same X-Auth-Token header. A small helper handles GET for each base and raises on a non-2xx response.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get_v2(path, params=None):
r = requests.get(f"{API_V2}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_get_v3(path, params=None):
r = requests.get(f"{API_V3}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
body = r.json()
return body.get("data", body)
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGetV2(path, params = {}) {
const url = new URL(`${API_V2}${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 bcGetV3(path, params = {}) {
const url = new URL(`${API_V3}${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 body = await res.json();
return body.data !== undefined ? body.data : body;
}
Pull each order's line items, header, and shipping addresses
For each order to audit, call GET /v2/orders/{order_id}/products to collect the distinct product_id values, GET /v2/orders/{order_id} for the status_id, and GET /v2/orders/{order_id}/shipping_addresses to see whether an address was ever recorded.
def order_header(order_id):
return bc_get_v2(f"/orders/{order_id}")
def order_line_items(order_id):
return bc_get_v2(f"/orders/{order_id}/products")
def order_shipping_addresses(order_id):
return bc_get_v2(f"/orders/{order_id}/shipping_addresses")
def product_type(product_id, cache):
if product_id in cache:
return cache[product_id]
product = bc_get_v3(f"/catalog/products/{product_id}")
product_type_value = product.get("type", "physical")
cache[product_id] = product_type_value
return product_type_value
async function orderHeader(orderId) {
return bcGetV2(`/orders/${orderId}`);
}
async function orderLineItems(orderId) {
return bcGetV2(`/orders/${orderId}/products`);
}
async function orderShippingAddresses(orderId) {
return bcGetV2(`/orders/${orderId}/shipping_addresses`);
}
async function productType(productId, cache) {
if (cache.has(productId)) return cache.get(productId);
const product = await bcGetV3(`/catalog/products/${productId}`);
const value = product.type || "physical";
cache.set(productId, value);
return value;
}
Decide, with one pure function
Keep the classification in its own function that takes the order's status_id, the list of resolved line item types, and whether a shipping address was found, and returns one of four outcomes. Status_id 0 (Incomplete), 5 (Cancelled), and 6 (Declined) are excluded up front, since an order in those states can legitimately lack an address regardless of what was in the cart.
EXCLUDED_STATUS_IDS = {0, 5, 6}
def classify_shipping_address_gap(
status_id: int, line_item_types: list, has_shipping_address: bool
) -> str:
if status_id in EXCLUDED_STATUS_IDS:
return "ok_excluded_status"
if has_shipping_address:
return "ok_has_address"
has_physical_item = any(t == "physical" for t in line_item_types)
if not has_physical_item:
return "ok_digital_only"
return "anomaly_missing_address"
const EXCLUDED_STATUS_IDS = new Set([0, 5, 6]);
export function classifyShippingAddressGap(statusId, lineItemTypes, hasShippingAddress) {
if (EXCLUDED_STATUS_IDS.has(statusId)) return "ok_excluded_status";
if (hasShippingAddress) return "ok_has_address";
const hasPhysicalItem = (lineItemTypes || []).some((t) => t === "physical");
if (!hasPhysicalItem) return "ok_digital_only";
return "anomaly_missing_address";
}
Annotate, do not invent an address
There is no API to retroactively attach a real shipping address to a placed order, and guessing one would corrupt fulfillment and tax data. When the classification is anomaly_missing_address, the only allowed write is a low-risk annotation, a staff_notes update through PUT /v2/orders/{id} flagging the order for manual review, guarded by the same dry run flag as everything else.
FLAG_NOTE = "missing shipping address - needs manual review"
def flag_order_for_review(order_id, existing_notes=""):
notes = existing_notes.strip()
if FLAG_NOTE in notes:
return None
merged = f"{notes}\n{FLAG_NOTE}".strip() if notes else FLAG_NOTE
return bc_put_v2(f"/orders/{order_id}", {"staff_notes": merged})
const FLAG_NOTE = "missing shipping address - needs manual review";
async function flagOrderForReview(orderId, existingNotes = "") {
const notes = existingNotes.trim();
if (notes.includes(FLAG_NOTE)) return null;
const merged = notes ? `${notes}\n${FLAG_NOTE}`.trim() : FLAG_NOTE;
return bcPutV2(`/orders/${orderId}`, { staff_notes: merged });
}
Wire it together with a dry run guard
The loop ties every piece together: pull the order header and status_id, pull the line items and resolve each product_id's type through a shared cache, pull shipping_addresses, classify, and only act on anomaly_missing_address. On the first few runs, leave DRY_RUN on so the script only prints the {order_id, status_id, physical product_ids, customer_id} tuple for each anomaly it finds. Read the output, agree with it, then switch it off to write the staff_notes annotation.
Always start with DRY_RUN=true. The only allowed write action, ever, is the low-risk staff_notes annotation or moving the order into a merchant-created "Awaiting Address" status. Never write a shipping_addresses record, BigCommerce does not expose an endpoint for that, and there is no safe way to guess a customer's real address.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, resolves and caches product types, classifies every order with the pure function, and only ever writes a staff_notes annotation, and only when DRY_RUN is false.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Flag BigCommerce orders with a physical line item but no shipping address.
BigCommerce only writes a shipping_addresses record on an order when the cart
that produced it contained at least one line item whose product type is
physical. An order made up entirely of digital line items, downloads,
services, or gift certificates never gets one, and GET
/v2/orders/{id}/shipping_addresses legitimately returns an empty array for
that order. That is expected behavior, not a bug. The real anomaly is a
physical line item with no address on file, most often caused by a custom or
headless checkout integration that created the order via the API and skipped
submitting consignments. This job audits a list of orders, resolves each line
item's product type, and flags only the orders where a physical item shipped
with no shipping address and the order is in a real post-checkout status.
There is no API to retroactively attach a real shipping address, so the only
write action is a staff_notes annotation, guarded by DRY_RUN. Safe to run
again and again.
Guide: https://www.allanninal.dev/bigcommerce/order-missing-shipping-address-no-physical-item/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_missing_shipping_addresses")
STORE_HASH = os.environ.get("BIGCOMMERCE_STORE_HASH", "example_hash")
ACCESS_TOKEN = os.environ.get("BIGCOMMERCE_ACCESS_TOKEN", "bc_dummy")
API_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EXCLUDED_STATUS_IDS = {0, 5, 6}
FLAG_NOTE = "missing shipping address - needs manual review"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get_v2(path, params=None):
r = requests.get(f"{API_V2}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else []
def bc_get_v3(path, params=None):
r = requests.get(f"{API_V3}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
body = r.json()
return body.get("data", body)
def bc_put_v2(path, body):
r = requests.put(f"{API_V2}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def classify_shipping_address_gap(
status_id: int, line_item_types: list, has_shipping_address: bool
) -> str:
"""Pure decision. No network, no side effects.
Returns one of:
ok_digital_only - no physical items, no address expected.
ok_has_address - shipping_addresses is non-empty.
ok_excluded_status - status_id in {0, 5, 6}, absence is inconclusive.
anomaly_missing_address - post-checkout status, a physical item is
present, and no shipping address exists.
"""
if status_id in EXCLUDED_STATUS_IDS:
return "ok_excluded_status"
if has_shipping_address:
return "ok_has_address"
has_physical_item = any(t == "physical" for t in line_item_types)
if not has_physical_item:
return "ok_digital_only"
return "anomaly_missing_address"
def orders_to_audit():
"""Page through orders created within the lookback window."""
page = 1
while True:
orders = bc_get_v2(
"/orders",
{
"min_date_created": f"-{LOOKBACK_DAYS} days",
"page": page,
"limit": 50,
},
)
if not orders:
return
for order in orders:
yield order
page += 1
def order_line_items(order_id):
return bc_get_v2(f"/orders/{order_id}/products")
def order_shipping_addresses(order_id):
return bc_get_v2(f"/orders/{order_id}/shipping_addresses")
def resolve_product_type(product_id, cache):
if product_id in cache:
return cache[product_id]
try:
product = bc_get_v3(f"/catalog/products/{product_id}")
value = product.get("type", "physical")
except requests.HTTPError:
# A deleted or inaccessible product is treated as physical, the
# conservative choice, so a real anomaly is never silently dropped.
value = "physical"
cache[product_id] = value
return value
def flag_order_for_review(order_id, existing_notes=""):
notes = (existing_notes or "").strip()
if FLAG_NOTE in notes:
return None
merged = f"{notes}\n{FLAG_NOTE}".strip() if notes else FLAG_NOTE
return bc_put_v2(f"/orders/{order_id}", {"staff_notes": merged})
def run():
product_type_cache = {}
anomalies = 0
digital_only = 0
for order in orders_to_audit():
order_id = order["id"]
status_id = order.get("status_id")
line_items = order_line_items(order_id)
product_ids = sorted({item["product_id"] for item in line_items or [] if item.get("product_id")})
line_item_types = [resolve_product_type(pid, product_type_cache) for pid in product_ids]
shipping_addresses = order_shipping_addresses(order_id)
has_shipping_address = bool(shipping_addresses)
classification = classify_shipping_address_gap(status_id, line_item_types, has_shipping_address)
if classification == "ok_digital_only":
digital_only += 1
log.info(
"order_id=%s status_id=%s ok_digital_only (no address expected)",
order_id, status_id,
)
continue
if classification != "anomaly_missing_address":
continue
physical_product_ids = [
pid for pid, t in zip(product_ids, line_item_types) if t == "physical"
]
customer_id = order.get("customer_id")
log.warning(
"anomaly_missing_address order_id=%s status_id=%s physical_product_ids=%s "
"customer_id=%s (%s)",
order_id, status_id, physical_product_ids, customer_id,
"dry run" if DRY_RUN else "flagging",
)
if not DRY_RUN:
flag_order_for_review(order_id, order.get("staff_notes", ""))
anomalies += 1
log.info(
"Done. %d anomal%s found, %d digital-only order(s) logged for visibility.",
anomalies, "y" if anomalies == 1 else "ies", digital_only,
)
if __name__ == "__main__":
run()
/**
* Flag BigCommerce orders with a physical line item but no shipping address.
*
* BigCommerce only writes a shipping_addresses record on an order when the cart
* that produced it contained at least one line item whose product type is
* physical. An order made up entirely of digital line items, downloads,
* services, or gift certificates never gets one, and GET
* /v2/orders/{id}/shipping_addresses legitimately returns an empty array for
* that order. That is expected behavior, not a bug. The real anomaly is a
* physical line item with no address on file, most often caused by a custom or
* headless checkout integration that created the order via the API and skipped
* submitting consignments. This job audits a list of orders, resolves each line
* item's product type, and flags only the orders where a physical item shipped
* with no shipping address and the order is in a real post-checkout status.
* There is no API to retroactively attach a real shipping address, so the only
* write action is a staff_notes annotation, guarded by DRY_RUN.
*
* Guide: https://www.allanninal.dev/bigcommerce/order-missing-shipping-address-no-physical-item/
*/
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_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EXCLUDED_STATUS_IDS = new Set([0, 5, 6]);
const FLAG_NOTE = "missing shipping address - needs manual review";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* Returns one of:
* ok_digital_only - no physical items, no address expected.
* ok_has_address - shipping_addresses is non-empty.
* ok_excluded_status - status_id in {0, 5, 6}, absence is inconclusive.
* anomaly_missing_address - post-checkout status, a physical item is
* present, and no shipping address exists.
*/
export function classifyShippingAddressGap(statusId, lineItemTypes, hasShippingAddress) {
if (EXCLUDED_STATUS_IDS.has(statusId)) return "ok_excluded_status";
if (hasShippingAddress) return "ok_has_address";
const hasPhysicalItem = (lineItemTypes || []).some((t) => t === "physical");
if (!hasPhysicalItem) return "ok_digital_only";
return "anomaly_missing_address";
}
async function bcGetV2(path, params = {}) {
const url = new URL(`${API_V2}${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 bcGetV3(path, params = {}) {
const url = new URL(`${API_V3}${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 body = await res.json();
return body.data !== undefined ? body.data : body;
}
async function bcPutV2(path, body) {
const res = await fetch(`${API_V2}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* ordersToAudit() {
let page = 1;
while (true) {
const orders = await bcGetV2("/orders", {
min_date_created: `-${LOOKBACK_DAYS} days`,
page,
limit: 50,
});
if (!orders.length) return;
for (const order of orders) yield order;
page += 1;
}
}
async function orderLineItems(orderId) {
return bcGetV2(`/orders/${orderId}/products`);
}
async function orderShippingAddresses(orderId) {
return bcGetV2(`/orders/${orderId}/shipping_addresses`);
}
async function resolveProductType(productId, cache) {
if (cache.has(productId)) return cache.get(productId);
let value = "physical";
try {
const product = await bcGetV3(`/catalog/products/${productId}`);
value = product.type || "physical";
} catch {
// A deleted or inaccessible product is treated as physical, the
// conservative choice, so a real anomaly is never silently dropped.
value = "physical";
}
cache.set(productId, value);
return value;
}
async function flagOrderForReview(orderId, existingNotes = "") {
const notes = (existingNotes || "").trim();
if (notes.includes(FLAG_NOTE)) return null;
const merged = notes ? `${notes}\n${FLAG_NOTE}`.trim() : FLAG_NOTE;
return bcPutV2(`/orders/${orderId}`, { staff_notes: merged });
}
export async function run() {
const productTypeCache = new Map();
let anomalies = 0;
let digitalOnly = 0;
for await (const order of ordersToAudit()) {
const orderId = order.id;
const statusId = order.status_id;
const lineItems = await orderLineItems(orderId);
const productIds = [...new Set((lineItems || []).map((item) => item.product_id).filter(Boolean))].sort(
(a, b) => a - b
);
const lineItemTypes = [];
for (const pid of productIds) {
lineItemTypes.push(await resolveProductType(pid, productTypeCache));
}
const shippingAddresses = await orderShippingAddresses(orderId);
const hasShippingAddress = Boolean(shippingAddresses && shippingAddresses.length);
const classification = classifyShippingAddressGap(statusId, lineItemTypes, hasShippingAddress);
if (classification === "ok_digital_only") {
digitalOnly += 1;
console.log(`order_id=${orderId} status_id=${statusId} ok_digital_only (no address expected)`);
continue;
}
if (classification !== "anomaly_missing_address") continue;
const physicalProductIds = productIds.filter((pid, i) => lineItemTypes[i] === "physical");
const customerId = order.customer_id;
console.warn(
`anomaly_missing_address order_id=${orderId} status_id=${statusId} ` +
`physical_product_ids=${JSON.stringify(physicalProductIds)} customer_id=${customerId} ` +
`(${DRY_RUN ? "dry run" : "flagging"})`
);
if (!DRY_RUN) await flagOrderForReview(orderId, order.staff_notes || "");
anomalies += 1;
}
console.log(
`Done. ${anomalies} anomaly(ies) found, ${digitalOnly} digital-only order(s) logged for visibility.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification rule is the part most worth testing, because it decides which orders get reported as broken. Because classify_shipping_address_gap takes only plain values and returns a plain string, the test needs no network and no BigCommerce store. It just feeds in plain values across the status_id, type-list, and boolean matrix and checks the answer.
from flag_missing_shipping_addresses import classify_shipping_address_gap
def test_digital_only_order_with_no_address_is_ok():
assert classify_shipping_address_gap(11, ["digital"], False) == "ok_digital_only"
def test_mixed_cart_with_no_address_is_ok_digital_only_when_no_physical_present():
assert classify_shipping_address_gap(11, ["digital", "digital"], False) == "ok_digital_only"
def test_order_with_address_is_ok_regardless_of_line_items():
assert classify_shipping_address_gap(11, ["digital"], True) == "ok_has_address"
assert classify_shipping_address_gap(11, ["physical"], True) == "ok_has_address"
def test_excluded_status_is_inconclusive_even_with_physical_item():
assert classify_shipping_address_gap(0, ["physical"], False) == "ok_excluded_status"
assert classify_shipping_address_gap(5, ["physical"], False) == "ok_excluded_status"
assert classify_shipping_address_gap(6, ["physical"], False) == "ok_excluded_status"
def test_physical_item_with_no_address_on_real_status_is_anomaly():
assert classify_shipping_address_gap(11, ["physical"], False) == "anomaly_missing_address"
def test_mixed_cart_with_physical_item_and_no_address_is_anomaly():
assert classify_shipping_address_gap(9, ["digital", "physical"], False) == "anomaly_missing_address"
def test_excluded_status_wins_over_missing_address_check():
assert classify_shipping_address_gap(0, [], False) == "ok_excluded_status"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyShippingAddressGap } from "./flag-missing-shipping-addresses.js";
test("digital-only order with no address is ok", () => {
assert.equal(classifyShippingAddressGap(11, ["digital"], false), "ok_digital_only");
});
test("mixed cart with no address is ok_digital_only when no physical present", () => {
assert.equal(classifyShippingAddressGap(11, ["digital", "digital"], false), "ok_digital_only");
});
test("order with address is ok regardless of line items", () => {
assert.equal(classifyShippingAddressGap(11, ["digital"], true), "ok_has_address");
assert.equal(classifyShippingAddressGap(11, ["physical"], true), "ok_has_address");
});
test("excluded status is inconclusive even with physical item", () => {
assert.equal(classifyShippingAddressGap(0, ["physical"], false), "ok_excluded_status");
assert.equal(classifyShippingAddressGap(5, ["physical"], false), "ok_excluded_status");
assert.equal(classifyShippingAddressGap(6, ["physical"], false), "ok_excluded_status");
});
test("physical item with no address on real status is anomaly", () => {
assert.equal(classifyShippingAddressGap(11, ["physical"], false), "anomaly_missing_address");
});
test("mixed cart with physical item and no address is anomaly", () => {
assert.equal(classifyShippingAddressGap(9, ["digital", "physical"], false), "anomaly_missing_address");
});
test("excluded status wins over missing address check", () => {
assert.equal(classifyShippingAddressGap(0, [], false), "ok_excluded_status");
});
Case studies
The store whose WMS rejected half its digital bundle orders
A store selling software license bundles alongside physical hardware had a 3PL sync job that pulled every new order and expected a shipping address on all of them. Every order for a pure license bundle came back with an empty shipping_addresses array, and the sync job rejected it as incomplete, paging an on-call engineer nightly for orders that were never going to ship anything.
Running the audit script against a week of orders showed the entire alert backlog was ok_digital_only. The WMS integration was updated to skip orders where every line item resolves to digital, and the nightly page count dropped to zero within a day.
The custom checkout that quietly skipped consignments
A merchant ran a custom mobile checkout that posted orders directly to the orders API for speed, bypassing the standard cart flow. A bug in that integration meant consignments were never submitted for a subset of orders, so a batch of orders with real hardware line items had no shipping address at all, and nobody noticed because the existing monitoring treated any empty shipping_addresses array as normal.
The audit script's anomaly_missing_address classification caught exactly this batch, since it checked the resolved product types rather than just the presence of an address. Support used each order's billing_address contact info to reach out and collect the missing ship-to details before the orders slipped past their fulfillment window.
After this runs on a schedule, every all-digital order is correctly left alone, logged only for visibility, and every order with a physical item and no shipping address gets flagged with enough detail, order_id, status_id, the physical product_ids, and customer_id, for support to follow up directly. No address is ever invented, and no order's fulfillment state is silently changed. The only automatic write is a staff_notes annotation, and only when DRY_RUN is turned off on purpose.
FAQ
Why does GET /v2/orders/{id}/shipping_addresses return an empty array?
BigCommerce only generates a shipping_addresses record when the cart or checkout that produced the order contained at least one line item whose product type is physical. If every line item is digital, a download, a service, or a gift certificate, checkout never asks for a ship-to address, so the order never gets one. An empty array from that endpoint is expected behavior for a digital-only order, not a bug.
How do I tell a legitimately digital order apart from a genuinely broken one?
Cross-reference the order's line items against the shipping_addresses array instead of assuming absence is always fine. Resolve each line item's product_id to its catalog type field with GET /v3/catalog/products/{product_id}. If at least one line item resolves to physical and shipping_addresses is still empty, and the order's status_id is a real post-checkout state, that is a true anomaly worth flagging. If every line item is digital, the empty array is correct by design.
Can I automatically attach a shipping address to these broken orders?
No. BigCommerce has no API to retroactively attach a customer's real shipping address to a placed order, and inventing one would corrupt fulfillment and tax data. The safe response is to flag the order for manual review, for example by writing a staff_notes annotation with PUT /v2/orders/{id} or moving it into a merchant-created Awaiting Address status, then have support follow up with the customer using the contact details on the order's billing_address.
Related field notes
Citations
On the problem:
- BigCommerce Support: shipping address is missing for this order. support.bigcommerce.com shipping address is missing for this order
- BigCommerce Support: 422, a shipping address for this order is incomplete. support.bigcommerce.com 422 shipping address incomplete
- BigCommerce Support: creating digital products. support.bigcommerce.com creating digital products
On the solution:
- BigCommerce Developer Center: order shipping addresses endpoint. developer.bigcommerce.com order shipping addresses
- BigCommerce Developer Center: order products endpoint. developer.bigcommerce.com order products
- BigCommerce Developer Center: orders overview. developer.bigcommerce.com orders overview
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear up a false alarm?
If this saved you from chasing digital orders that were never broken, or caught a real gap your monitoring was missing, 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