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.

Python and Node.js BigCommerce V2 Orders API Safe by default (dry run)
Brown cardboard box on white surface
Photo by Christopher Bill on Unsplash
The short answer

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.

Integration wants order shipped PUT /v2/orders/{id} {"status": "Shipped"} Not a field Ignored or rejected status_id unchanged
The status text is a read-only label computed from status_id. Writing it back does not round-trip, so the order silently stays where it was.

Why it happens

This gap keeps showing up in BigCommerce integrations for a few concrete reasons:

See the citations at the end for the exact support threads and docs.

The key insight

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.

GET /v2/order_statuses once per run Build name to id map name, system_label, custom_label Resolve desired status int or case-insensitive name Resolved and allowlisted? yes no, flag or skip PUT status_id verify, retry once
The script only writes status_id, resolved against the store's live status list, and only for ids on an explicit allowlist. Anything unresolved is flagged, never sent as raw text.

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 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.

setup (shell)
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
setup (shell)
// 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
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 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.

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

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.

step3.py
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
step3.js
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;
}
4

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."

resolve.py
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
resolve.js
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;
}
5

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.

apply.py
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
apply.js
async function writeStatusId(orderId, targetStatusId) {
  await bcPut(`/orders/${orderId}`, { status_id: targetStatusId });
  const updated = await bcGet(`/orders/${orderId}`);
  return updated.status_id === targetStatusId;
}
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_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.

Run it safe

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.

write_order_status_id.py
"""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.")
write-order-status-id.js
/**
 * 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.

test_order_status_resolution.py
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
write-order-status-id.test.js
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

Silent no-op

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.

Custom labels

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.

What good looks like

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:

  1. BigCommerce Support: changing order status via the API. support.bigcommerce.com change order status via api
  2. BigCommerce Support: updating an order from the API. support.bigcommerce.com update order from api
  3. BigCommerce Developer Center: order status reference and status_id values. developer.bigcommerce.com order status

On the solution:

  1. BigCommerce Developer Center: the V2 Orders API and PUT /v2/orders/{id}. developer.bigcommerce.com orders
  2. BigCommerce API Reference: GET /v2/order_statuses. docs.bigcommerce.com list order statuses
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes