Repair Orders
Writing order status text instead of status_id fails or no-ops
The order shows "Awaiting Fulfillment" in the admin, so a script sends {"status": "Shipped"} to move it forward. Nothing happens, or the API rejects the call outright. BigCommerce's V2 Orders resource models order state as a numeric status_id. The status text you see is a read-only label the server computes from that id, not a field you can write to. Here is why that label can never be sent back, and a small script that resolves any status name to its integer status_id before writing.
BigCommerce's V2 Orders resource stores order state as an integer status_id. The status string returned by GET /v2/orders/{id}, for example "Awaiting Fulfillment", is generated server-side from that id and the store's Control Panel status-label customization. It is a display label, not an independent writable property. Sending PUT /v2/orders/{id} with {"status": "Shipped"} either gets silently ignored, leaving status_id unchanged, or gets rejected if the endpoint validates strictly. It never maps the English text back to an id. The fix is to call GET /v2/order_statuses once to build a name-to-id map for your store, resolve the desired status name through that map, and send only {"status_id": <int>}. Full code, tests, and a dry run guard are below.
The problem in plain words
Every BigCommerce order has a status_id, a small integer from the fixed set BigCommerce defines: 0 Incomplete, 1 Pending, 2 Shipped, 3 Partially Shipped, 4 Refunded, 5 Cancelled, 6 Declined, 7 Awaiting Payment, 8 Awaiting Pickup, 9 Awaiting Shipment, 10 Completed, 11 Awaiting Fulfillment, 12 Manual Verification Required, 13 Disputed, 14 Partially Refunded. When you fetch an order, the API also returns a status string, the human-readable label for that id, so your dashboard can show "Awaiting Fulfillment" instead of the number 11.
That label is computed on the way out, not stored on the way in. It comes from the id plus whatever custom label the merchant configured for that status in the Control Panel, because BigCommerce lets stores rename status labels per store. When an integration writes back to the order, it has to speak status_id, the same field the API reads from. A payload of {"status": "Shipped"} is not recognized as the thing that drives state. Depending on how strictly the endpoint validates the body, the field is either dropped and the write is a silent no-op, or the request is rejected. Either way, the order's status_id never changes, and there is no server-side lookup that turns "Shipped" back into 2 for you.
Why it happens
This gap keeps showing up in BigCommerce integrations for a few concrete reasons:
- The
GET /v2/orders/{id}response includes bothstatusandstatus_id, so it is an easy mistake to copy the human-readable field into the write payload, since it looks like the same piece of data going in the same door it came out of. - Status labels are customizable per store in the Control Panel. Two stores can label status_id 11 differently, so even if you hardcode "Awaiting Fulfillment" as a string to write, that exact text is not a stable identifier across stores, or even guaranteed stable in one store over time.
- The V2 Orders endpoint's handling of unrecognized or read-only fields in the request body is not always strict, so the failure mode is often a silent no-op rather than a loud 4xx, which makes the bug easy to miss until someone notices the order never actually moved.
- This exact confusion, sending a status string instead of status_id, is a recurring question in BigCommerce's own support community, which is a signal that the API surface invites the mistake rather than it being a one-off scripting error.
See the citations at the end for the exact support threads and docs.
Status text is output, not input. The only writable representation of order state on the V2 Orders resource is the integer status_id. Because that mapping is store-customizable, you cannot hardcode a name-to-id table once and reuse it everywhere. Call GET /v2/order_statuses for the store you are writing to, build a case-insensitive map from name, system_label, and any custom_label to id, resolve the desired status through that map, and write only status_id. If the name does not resolve, do not fall back to sending the raw string. Skip the write and flag it.
The fix, as a flow
We do not guess at ids. We fetch the store's own status list once, resolve whatever the caller asked for (an id or a name) against that list, and only then write the integer back.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Orders (modify) scope so it can read order statuses and update status_id. 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 ALLOWED_STATUS_IDS="2,9,10,11"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export ALLOWED_STATUS_IDS="2,9,10,11"
export DRY_RUN="true" // start safe, change to false to write
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 and Accept: application/json. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to fetch order statuses, read the order, and write the status update.
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()
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();
}
Fetch the store's order statuses and build the name to id map
Call GET /v2/order_statuses once at the start of a run. It returns the authoritative list of {id, name, system_label, custom_label} for this store. Build a case-insensitive map from every label variant, name, system_label, and any store-customized custom_label, to its id. Rebuild it per run instead of caching it across stores or across deploys, since a merchant can rename a label in the Control Panel at any time.
def fetch_status_map():
"""Returns {lowercased label: status_id} built from GET /v2/order_statuses."""
statuses = bc_get("/order_statuses")
status_map = {}
for entry in statuses or []:
status_id = entry.get("id")
if status_id is None:
continue
for label in (entry.get("name"), entry.get("system_label"), entry.get("custom_label")):
if label:
status_map[label.strip().lower()] = status_id
return status_map
async function fetchStatusMap() {
// Returns { loweredLabel: statusId } built from GET /v2/order_statuses
const statuses = await bcGet("/order_statuses");
const statusMap = {};
for (const entry of statuses || []) {
const statusId = entry.id;
if (statusId === undefined || statusId === null) continue;
for (const label of [entry.name, entry.system_label, entry.custom_label]) {
if (label) statusMap[label.trim().toLowerCase()] = statusId;
}
}
return statusMap;
}
Resolve with one pure function, never fall back to the raw label
Keep the resolution logic in its own function that takes whatever the caller asked for, an int, a numeric string, or a name, plus the status map and the set of valid ids, and returns an int or None. If it is already numeric, it is only valid if it is one of the fourteen known status ids. If it is text, normalize it and look it up in the map. If nothing matches, return None. Treat None as "do not write," never as "send the string anyway."
VALID_STATUS_IDS = frozenset(range(0, 15))
def resolve_status_id(desired, status_map, valid_ids=VALID_STATUS_IDS):
if isinstance(desired, bool):
return None
if isinstance(desired, int):
return desired if desired in valid_ids else None
if isinstance(desired, str):
stripped = desired.strip()
if stripped.lstrip("-").isdigit():
candidate = int(stripped)
return candidate if candidate in valid_ids else None
return status_map.get(stripped.lower())
return None
const VALID_STATUS_IDS = new Set(Array.from({ length: 15 }, (_, i) => i));
export function resolveStatusId(desired, statusMap, validIds = VALID_STATUS_IDS) {
if (typeof desired === "boolean") return null;
if (typeof desired === "number" && Number.isInteger(desired)) {
return validIds.has(desired) ? desired : null;
}
if (typeof desired === "string") {
const stripped = desired.trim();
if (/^-?\d+$/.test(stripped)) {
const candidate = Number.parseInt(stripped, 10);
return validIds.has(candidate) ? candidate : null;
}
const match = statusMap[stripped.toLowerCase()];
return match === undefined ? null : match;
}
return null;
}
Write status_id only, behind an allowlist, and verify
Once resolved, check the id against an explicit allowlist of target statuses your integration is permitted to set. Status transitions can trigger side effects like shipment or refund webhooks, so this is a real write, not a pure reconciliation. Call PUT /v2/orders/{id} with {"status_id": <int>}, nothing else in the body, then re-fetch the order and confirm status_id matches. Retry once on mismatch before flagging for manual review.
def write_status_id(order_id, target_status_id):
bc_put(f"/orders/{order_id}", {"status_id": target_status_id})
updated = bc_get(f"/orders/{order_id}")
return updated.get("status_id") == target_status_id
async function writeStatusId(orderId, targetStatusId) {
await bcPut(`/orders/${orderId}`, { status_id: targetStatusId });
const updated = await bcGet(`/orders/${orderId}`);
return updated.status_id === targetStatusId;
}
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_id, from_status_id, to_status_id} triple it would write. Read the output, confirm the resolved ids are what you expect, then switch it off. Keep ALLOWED_STATUS_IDS tight, only the target statuses this integration is actually meant to set.
Never write a raw status string, and never write a status_id that resolved to None or that falls outside your allowlist. Status transitions are real writes that can trigger shipment or refund webhooks, so verify after every write and retry once on mismatch before escalating to a human.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, fetches the store's live status list, resolves the desired status through the pure function, respects the dry run flag and the allowlist, and verifies every real write by re-fetching the order.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Resolve a BigCommerce order status name to its status_id before writing it.
BigCommerce's V2 Orders resource models order state as a numeric status_id.
The status field returned by GET /v2/orders/{id}, for example "Awaiting
Fulfillment", is a read-only label the server computes from that id and the
store's Control Panel status-label customization. It is not an independent
writable property. Sending PUT /v2/orders/{id} with {"status": "Shipped"}
either gets silently ignored, leaving status_id unchanged, or gets rejected
if the endpoint validates strictly. It never maps the label back to an id.
This job fetches the store's own GET /v2/order_statuses list, builds a
case-insensitive name-to-id map, resolves the desired status (an int or a
name) through that map with resolve_status_id, and writes only status_id,
never the raw string. Every write is checked against an explicit allowlist
of permitted target status ids, and every write is verified by re-fetching
the order and retried once on mismatch before being flagged for review.
Guide: https://www.allanninal.dev/bigcommerce/order-status-write-requires-status-id/
"""
import os
import logging
from typing import Optional, Union
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("write_order_status_id")
STORE_HASH = os.environ.get("BIGCOMMERCE_STORE_HASH", "example_hash")
ACCESS_TOKEN = os.environ.get("BIGCOMMERCE_ACCESS_TOKEN", "bc_dummy")
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ALLOWED_STATUS_IDS = frozenset(
int(x) for x in os.environ.get("ALLOWED_STATUS_IDS", "2,9,10,11").split(",") if x.strip()
)
VALID_STATUS_IDS = frozenset(range(0, 15))
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 resolve_status_id(
desired: Union[str, int], status_map: dict, valid_ids: frozenset = VALID_STATUS_IDS
) -> Optional[int]:
"""Pure decision. No network, no side effects.
If desired is already an int (or a numeric string), return it only if it
is in valid_ids, else None. If desired is a text label, normalize
(strip/lower) and look it up in status_map (built from name,
system_label, custom_label on GET /v2/order_statuses); return the
matched id, or None if there is no case-insensitive match. Never returns
a string. Callers must treat None as "do not write," not as a signal to
fall back to sending the raw label.
"""
if isinstance(desired, bool):
return None
if isinstance(desired, int):
return desired if desired in valid_ids else None
if isinstance(desired, str):
stripped = desired.strip()
if stripped.lstrip("-").isdigit():
candidate = int(stripped)
return candidate if candidate in valid_ids else None
return status_map.get(stripped.lower())
return None
def fetch_status_map():
"""Returns {lowercased label: status_id} built from GET /v2/order_statuses."""
statuses = bc_get("/order_statuses")
status_map = {}
for entry in statuses or []:
status_id = entry.get("id")
if status_id is None:
continue
for label in (entry.get("name"), entry.get("system_label"), entry.get("custom_label")):
if label:
status_map[label.strip().lower()] = status_id
return status_map
def write_status_id(order_id, target_status_id, attempt=1, max_attempts=2):
bc_put(f"/orders/{order_id}", {"status_id": target_status_id})
updated = bc_get(f"/orders/{order_id}")
if updated.get("status_id") == target_status_id:
return True
if attempt < max_attempts:
return write_status_id(order_id, target_status_id, attempt + 1, max_attempts)
return False
def run(order_id, desired_status):
status_map = fetch_status_map()
resolved = resolve_status_id(desired_status, status_map)
if resolved is None:
log.warning(
"order_id=%s desired=%r did not resolve to a known status_id, skipping write",
order_id, desired_status,
)
return
if resolved not in ALLOWED_STATUS_IDS:
log.warning(
"order_id=%s resolved status_id=%s is not in ALLOWED_STATUS_IDS=%s, flagging for review",
order_id, resolved, sorted(ALLOWED_STATUS_IDS),
)
return
current = bc_get(f"/orders/{order_id}")
from_status_id = current.get("status_id")
log.info(
"order_id=%s from_status_id=%s to_status_id=%s (%s)",
order_id, from_status_id, resolved, "dry run" if DRY_RUN else "writing",
)
if DRY_RUN:
return
if from_status_id == resolved:
log.info("order_id=%s already at status_id=%s, no write needed", order_id, resolved)
return
ok = write_status_id(order_id, resolved)
if ok:
log.info("order_id=%s verified at status_id=%s", order_id, resolved)
else:
log.warning(
"order_id=%s status_id mismatch after write and retry, flagging for manual review",
order_id,
)
if __name__ == "__main__":
target_order_id = os.environ.get("ORDER_ID")
target_status = os.environ.get("DESIRED_STATUS", "Shipped")
if target_order_id:
run(int(target_order_id), target_status)
else:
log.info("Set ORDER_ID and DESIRED_STATUS to run against a real order.")
/**
* Resolve a BigCommerce order status name to its status_id before writing it.
*
* BigCommerce's V2 Orders resource models order state as a numeric status_id.
* The status field returned by GET /v2/orders/{id}, for example "Awaiting
* Fulfillment", is a read-only label the server computes from that id and the
* store's Control Panel status-label customization. It is not an independent
* writable property. Sending PUT /v2/orders/{id} with {"status": "Shipped"}
* either gets silently ignored, leaving status_id unchanged, or gets rejected
* if the endpoint validates strictly. It never maps the label back to an id.
*
* This job fetches the store's own GET /v2/order_statuses list, builds a
* case-insensitive name-to-id map, resolves the desired status (a number or a
* name) through that map with resolveStatusId, and writes only status_id,
* never the raw string. Every write is checked against an explicit allowlist
* of permitted target status ids, and every write is verified by re-fetching
* the order and retried once on mismatch before being flagged for review.
*
* Guide: https://www.allanninal.dev/bigcommerce/order-status-write-requires-status-id/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ALLOWED_STATUS_IDS = new Set(
(process.env.ALLOWED_STATUS_IDS || "2,9,10,11")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.map(Number)
);
const VALID_STATUS_IDS = new Set(Array.from({ length: 15 }, (_, i) => i));
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* If desired is already a number (or a numeric string), return it only if it
* is in validIds, else null. If desired is a text label, normalize
* (trim/lowercase) and look it up in statusMap (built from name,
* system_label, custom_label on GET /v2/order_statuses); return the matched
* id, or null if there is no case-insensitive match. Never returns a string.
* Callers must treat null as "do not write," not as a signal to fall back to
* sending the raw label.
*/
export function resolveStatusId(desired, statusMap, validIds = VALID_STATUS_IDS) {
if (typeof desired === "boolean") return null;
if (typeof desired === "number" && Number.isInteger(desired)) {
return validIds.has(desired) ? desired : null;
}
if (typeof desired === "string") {
const stripped = desired.trim();
if (/^-?\d+$/.test(stripped)) {
const candidate = Number.parseInt(stripped, 10);
return validIds.has(candidate) ? candidate : null;
}
const match = statusMap[stripped.toLowerCase()];
return match === undefined ? null : match;
}
return null;
}
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 fetchStatusMap() {
// Returns { loweredLabel: statusId } built from GET /v2/order_statuses
const statuses = await bcGet("/order_statuses");
const statusMap = {};
for (const entry of statuses || []) {
const statusId = entry.id;
if (statusId === undefined || statusId === null) continue;
for (const label of [entry.name, entry.system_label, entry.custom_label]) {
if (label) statusMap[label.trim().toLowerCase()] = statusId;
}
}
return statusMap;
}
async function writeStatusId(orderId, targetStatusId, attempt = 1, maxAttempts = 2) {
await bcPut(`/orders/${orderId}`, { status_id: targetStatusId });
const updated = await bcGet(`/orders/${orderId}`);
if (updated.status_id === targetStatusId) return true;
if (attempt < maxAttempts) return writeStatusId(orderId, targetStatusId, attempt + 1, maxAttempts);
return false;
}
export async function run(orderId, desiredStatus) {
const statusMap = await fetchStatusMap();
const resolved = resolveStatusId(desiredStatus, statusMap);
if (resolved === null) {
console.warn(
`order_id=${orderId} desired=${JSON.stringify(desiredStatus)} did not resolve to a known status_id, skipping write`
);
return;
}
if (!ALLOWED_STATUS_IDS.has(resolved)) {
console.warn(
`order_id=${orderId} resolved status_id=${resolved} is not in ALLOWED_STATUS_IDS=[${[...ALLOWED_STATUS_IDS].sort()}], flagging for review`
);
return;
}
const current = await bcGet(`/orders/${orderId}`);
const fromStatusId = current.status_id;
console.log(
`order_id=${orderId} from_status_id=${fromStatusId} to_status_id=${resolved} (${DRY_RUN ? "dry run" : "writing"})`
);
if (DRY_RUN) return;
if (fromStatusId === resolved) {
console.log(`order_id=${orderId} already at status_id=${resolved}, no write needed`);
return;
}
const ok = await writeStatusId(orderId, resolved);
if (ok) {
console.log(`order_id=${orderId} verified at status_id=${resolved}`);
} else {
console.warn(`order_id=${orderId} status_id mismatch after write and retry, flagging for manual review`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const orderId = process.env.ORDER_ID;
const desiredStatus = process.env.DESIRED_STATUS || "Shipped";
if (orderId) {
run(Number(orderId), desiredStatus).catch((err) => { console.error(err); process.exit(1); });
} else {
console.log("Set ORDER_ID and DESIRED_STATUS to run against a real order.");
}
}
Add a test
The resolution rule is the part most worth testing, because it decides whether a real write happens at all. Because resolve_status_id takes only plain values and returns a plain value, the test needs no network and no BigCommerce store. It just feeds in a status map and checks the answer.
from write_order_status_id import resolve_status_id
STATUS_MAP = {
"incomplete": 0, "pending": 1, "shipped": 2, "partially shipped": 3,
"refunded": 4, "cancelled": 5, "declined": 6, "awaiting payment": 7,
"awaiting pickup": 8, "awaiting shipment": 9, "completed": 10,
"awaiting fulfillment": 11, "manual verification required": 12,
"disputed": 13, "partially refunded": 14,
}
def test_resolves_valid_int_status_id():
assert resolve_status_id(2, STATUS_MAP) == 2
def test_rejects_out_of_range_int_status_id():
assert resolve_status_id(999, STATUS_MAP) is None
def test_resolves_case_insensitive_name():
assert resolve_status_id("Shipped", STATUS_MAP) == 2
assert resolve_status_id(" shipped ", STATUS_MAP) == 2
def test_resolves_numeric_string():
assert resolve_status_id("11", STATUS_MAP) == 11
def test_unknown_name_returns_none_not_the_string():
result = resolve_status_id("Shipped Today", STATUS_MAP)
assert result is None
assert not isinstance(result, str)
def test_boolean_is_never_treated_as_valid_status_id():
assert resolve_status_id(True, STATUS_MAP) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveStatusId } from "./write-order-status-id.js";
const STATUS_MAP = {
incomplete: 0, pending: 1, shipped: 2, "partially shipped": 3,
refunded: 4, cancelled: 5, declined: 6, "awaiting payment": 7,
"awaiting pickup": 8, "awaiting shipment": 9, completed: 10,
"awaiting fulfillment": 11, "manual verification required": 12,
disputed: 13, "partially refunded": 14,
};
test("resolves valid int status_id", () => {
assert.equal(resolveStatusId(2, STATUS_MAP), 2);
});
test("rejects out of range int status_id", () => {
assert.equal(resolveStatusId(999, STATUS_MAP), null);
});
test("resolves case-insensitive name", () => {
assert.equal(resolveStatusId("Shipped", STATUS_MAP), 2);
assert.equal(resolveStatusId(" shipped ", STATUS_MAP), 2);
});
test("resolves numeric string", () => {
assert.equal(resolveStatusId("11", STATUS_MAP), 11);
});
test("unknown name returns null, not the string", () => {
const result = resolveStatusId("Shipped Today", STATUS_MAP);
assert.equal(result, null);
assert.notEqual(typeof result, "string");
});
test("boolean is never treated as a valid status_id", () => {
assert.equal(resolveStatusId(true, STATUS_MAP), null);
});
Case studies
The fulfillment app that never moved a single order
A third-party fulfillment integration read status from each order, mapped it to its own internal state machine, and sent {"status": "Shipped"} back once a shipment was created. The write always returned success, so nobody noticed anything was wrong for weeks. Every order it touched stayed at whatever status_id it already had.
The fix was small once found: fetch GET /v2/order_statuses, resolve "Shipped" to status_id 2 through the real map, and send that integer instead. The same shipment-created event now reliably moves the order, and the fix cost nothing on the fulfillment side of the integration.
The store that renamed "Awaiting Fulfillment" to "Ready to Pack"
A merchant customized their status labels in the Control Panel so staff would see wording that matched their internal warehouse process. An integration built against a hardcoded id-to-name table kept sending the old default label, which of course matched nothing in this store's actual configuration and never resolved to anything.
Switching to a per-run GET /v2/order_statuses call solved it for this store and every future one, because the name-to-id map is now built from what the store actually has configured, not from a table written once and forgotten.
After this runs, no write ever leaves as a raw status string. Every status change resolves against the store's own live GET /v2/order_statuses list, only writes the integer status_id, only targets ids on an explicit allowlist, and is verified by re-fetching the order afterward. A desired status that does not resolve, or resolves outside the allowlist, is flagged and left alone instead of silently no-op'd or blindly sent as text.
FAQ
Why does sending {"status": "Shipped"} to the Orders API not update the order?
The status field on a BigCommerce order is a read-only label generated server-side from the numeric status_id and the store's Control Panel status-label customization. It is not an independent writable property, so the V2 Orders endpoint either ignores the unrecognized field and leaves status_id unchanged, or rejects the payload outright if validated strictly. It never maps the English label back to an id for you.
How do I know which integer status_id to send?
Call GET /v2/order_statuses to get the authoritative list of {id, name, system_label, custom_label} for your store, then build a case-insensitive map from name, system_label, and any store-customized custom_label to id. Status labels are customizable per store in the Control Panel, so a hardcoded id-to-name table can drift; always resolve against the live list.
Is writing status_id a safe operation to automate?
Treat it as a real write, not a pure reconciliation. Status transitions can trigger side effects like shipment or refund webhooks, so guard every write behind an explicit allowlist of permitted target status_ids and a DRY_RUN flag, then verify by re-fetching the order and comparing status_id before and after, retrying once on mismatch before flagging for manual review.
Related field notes
Citations
On the problem:
- BigCommerce Support: changing order status via the API. support.bigcommerce.com change order status via api
- BigCommerce Support: updating an order from the API. support.bigcommerce.com update order from api
- BigCommerce Developer Center: order status reference and status_id values. developer.bigcommerce.com order status
On the solution:
- BigCommerce Developer Center: the V2 Orders API and PUT /v2/orders/{id}. developer.bigcommerce.com orders
- BigCommerce API Reference: GET /v2/order_statuses. docs.bigcommerce.com list order statuses
- BigCommerce API Reference: GET a single order status by id. docs.bigcommerce.com get order status by id
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 get your order status writes actually landing?
If this saved you from a silent no-op or a rejected write, 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