Repair Migration and housekeeping
Backfill order metadata for matching
Before the ERP or marketplace integration was wired up, orders came in with no external_id, no external_merchant_id, and no external_source. Now a reconciliation job needs to join those old BigCommerce orders to ERP records and has nothing solid to join on. You cannot just PUT the missing fields back in, BigCommerce will not accept it. Here is why, and a small script that writes a safe reconciliation key somewhere BigCommerce will actually let you keep it.
external_id and external_merchant_id are only reliably set when an order is created. BigCommerce treats external_merchant_id as write-once, so a later PUT /v2/orders/{id} trying to add or change it returns a 400, and support threads show external_id behaving the same way once the order already exists. Do not fight that. Instead, match each legacy order to its ERP record with your own logic, then write an idempotent tag like [RECON:ext_id=ERP-00219482;source=M-MIG;matched=2026-07-10] into staff_notes, a control panel only field that stays mutable for the life of the order. Skip anything already tagged and skip status_id 0, 5, and 6. Full code, tests, and a dry run guard are below.
The problem in plain words
An external_id or external_merchant_id is supposed to be the thread that ties a BigCommerce order back to whatever system actually owns the order: an ERP, a marketplace, a new order-management layer. That thread only gets tied when the order is created, because those fields are populated by whichever client submitted the order at POST time.
If the integration did not exist yet when an order was placed, nothing was there to set the field, so it was simply left null. That order shipped, got paid, maybe got refunded, and sits in BigCommerce today with no durable key back to the ERP. Once the integration finally goes live and someone tries to reconcile the backlog, they discover the fields cannot be patched in after the fact. The join has to fall back to customer email, order total, and date, which drifts and collides at any real scale.
Why it happens
This is not a bug, it is how BigCommerce scopes those fields. A few things make it show up in real stores:
- An ERP, marketplace connector, or custom order-management integration was turned on well after the store went live, so every order before that date has no external identifiers at all.
- BigCommerce treats external_merchant_id as write-once. Once it has a value, or once the order exists without one, a PUT that tries to set or change it comes back with a 400.
- external_id behaves the same way in practice. Support threads document PUT requests that try to add or alter it on an existing order failing outright or being silently ignored, because the field is meant to be supplied by the client at order creation, not patched in later.
- Store migrations, platform re-launches, and a new integration going live for the first time all create the same gap: a population of legacy orders with a real business history but no cross-system key.
The result is a stack of orders that need reconciling and no clean way to join them. See the citations at the end for the exact support threads and docs.
Do not fight the write-once field. external_id and external_merchant_id are closed doors on an existing order, and trying to force them wastes time and produces 400s. staff_notes is the field BigCommerce actually leaves open, control panel only, always mutable. So the fix is not "restore the original field," it is "store the reconciliation key somewhere durable and idempotent," and let a human or a high-confidence match decide when that key gets written.
The fix, as a flow
We scan the pre-cutover date range, pull each order's full payload, and check whether it already has an external key. If it does not, we compare it against the ERP export to compute a candidate match and a confidence score. High confidence writes a real reconciliation tag. Low confidence writes an UNMATCHED tag instead of guessing. Either way, the write only ever touches staff_notes, and it is a dry run first.
Build it step by step
Get a store hash and an access token
Create an API account in your BigCommerce control panel under Settings, API, Store-level API accounts, or reuse an existing one. It needs the Orders scope with modify access, since we still need to PUT staff_notes. Keep the store hash and token in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MIGRATION_CUTOFF="2025-01-01T00:00:00+00:00"
export CUTOVER_DATE="2025-06-01T00:00:00+00:00"
export MATCH_CONFIDENCE_THRESHOLD="0.8"
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 MIGRATION_CUTOFF="2025-01-01T00:00:00+00:00"
export CUTOVER_DATE="2025-06-01T00:00:00+00:00"
export MATCH_CONFIDENCE_THRESHOLD="0.8"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V2 orders REST API
Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET and PUT so the rest of the script does not repeat headers and error handling.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
HEADERS = {"X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json"}
def bc_get(path, params=None):
r = requests.get(BASE + path, headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put(path, body):
r = requests.put(BASE + path, headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const HEADERS = { "X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json" };
async function bcGet(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(BASE + path + (qs ? `?${qs}` : ""), { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(BASE + path, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
Scan the pre-migration window and flag unmatched orders
List orders between the migration cutoff and the cutover date, sorted by id, then GET each one's full payload. An order needs backfill when external_id, external_merchant_id, and external_source are all missing or the source is not one of your expected integration tags, such as the M-MIG marker.
EXPECTED_SOURCE_TAGS = {"M-MIG"}
def needs_backfill(order):
if order.get("external_id"):
return False
if order.get("external_merchant_id"):
return False
source = order.get("external_source")
if source and source in EXPECTED_SOURCE_TAGS:
return False
return True
def scan_candidate_orders(min_date_created, max_date_created):
orders = bc_get("v2/orders", {
"min_date_created": min_date_created,
"max_date_created": max_date_created,
"is_deleted": "false",
"sort": "id",
"limit": 250,
})
out = []
for stub in orders:
full = bc_get(f"v2/orders/{stub['id']}")
if needs_backfill(full):
out.append(full)
return out
const EXPECTED_SOURCE_TAGS = new Set(["M-MIG"]);
function needsBackfill(order) {
if (order.external_id) return false;
if (order.external_merchant_id) return false;
const source = order.external_source;
if (source && EXPECTED_SOURCE_TAGS.has(source)) return false;
return true;
}
async function scanCandidateOrders(minDateCreated, maxDateCreated) {
const orders = await bcGet("v2/orders", {
min_date_created: minDateCreated,
max_date_created: maxDateCreated,
is_deleted: "false",
sort: "id",
limit: 250,
});
const out = [];
for (const stub of orders) {
const full = await bcGet(`v2/orders/${stub.id}`);
if (needsBackfill(full)) out.push(full);
}
return out;
}
Decide, with one pure function
The decision belongs in its own function that takes an order, a candidate match (or none), and the current time, and returns an action. No network, no side effects, which makes it trivial to test. It skips incomplete or voided orders, skips orders that are already tagged or already have an external key, writes a real reconciliation tag when the match is confident, and otherwise writes an UNMATCHED tag rather than guessing.
VOID_STATUS_IDS = {0, 5, 6} # Incomplete, Cancelled, Declined
CONFIDENCE_THRESHOLD = 0.8
def decide_backfill_action(order, candidate_match, now_iso):
if order["status_id"] in VOID_STATUS_IDS:
return {"action": "skip", "reason": "incomplete_or_voided"}
existing_notes = order.get("staff_notes") or ""
if "[RECON:" in existing_notes:
return {"action": "skip", "reason": "already_tagged"}
if order.get("external_id") or order.get("external_merchant_id"):
return {"action": "skip", "reason": "already_has_external_key"}
if candidate_match is None or candidate_match.get("confidence", 0) < CONFIDENCE_THRESHOLD:
return {
"action": "flag_unmatched",
"new_staff_notes": existing_notes + f"\n[RECON:UNMATCHED;checked={now_iso}]",
}
return {
"action": "write_staff_notes",
"new_staff_notes": existing_notes
+ f"\n[RECON:ext_id={candidate_match['external_id']};"
+ f"source={candidate_match.get('source', 'M-MIG')};matched={now_iso}]",
}
const VOID_STATUS_IDS = new Set([0, 5, 6]); // Incomplete, Cancelled, Declined
const CONFIDENCE_THRESHOLD = 0.8;
export function decideBackfillAction(order, candidateMatch, nowIso) {
if (VOID_STATUS_IDS.has(order.status_id)) {
return { action: "skip", reason: "incomplete_or_voided" };
}
const existingNotes = order.staff_notes || "";
if (existingNotes.includes("[RECON:")) {
return { action: "skip", reason: "already_tagged" };
}
if (order.external_id || order.external_merchant_id) {
return { action: "skip", reason: "already_has_external_key" };
}
if (!candidateMatch || (candidateMatch.confidence || 0) < CONFIDENCE_THRESHOLD) {
return {
action: "flag_unmatched",
new_staff_notes: `${existingNotes}\n[RECON:UNMATCHED;checked=${nowIso}]`,
};
}
return {
action: "write_staff_notes",
new_staff_notes:
`${existingNotes}\n[RECON:ext_id=${candidateMatch.external_id};` +
`source=${candidateMatch.source || "M-MIG"};matched=${nowIso}]`,
};
}
Write only staff_notes, guarded by a re-GET
Right before writing, re-GET the order to confirm staff_notes still does not contain a [RECON: block. That closes the race where two runs process the same backlog. Then PUT only the staff_notes field, never external_id or external_merchant_id, since those are the fields BigCommerce will reject or silently ignore.
def apply_staff_notes(order_id, new_staff_notes):
fresh = bc_get(f"v2/orders/{order_id}")
if "[RECON:" in (fresh.get("staff_notes") or ""):
return # another run already tagged it, idempotent no-op
return bc_put(f"v2/orders/{order_id}", {"staff_notes": new_staff_notes})
async function applyStaffNotes(orderId, newStaffNotes) {
const fresh = await bcGet(`v2/orders/${orderId}`);
if ((fresh.staff_notes || "").includes("[RECON:")) {
return; // another run already tagged it, idempotent no-op
}
return bcPut(`v2/orders/${orderId}`, { staff_notes: newStaffNotes });
}
Wire it together with a dry run guard
The loop matches each candidate order against your ERP export, runs decide_backfill_action, and logs what it would do. On the first few runs, leave DRY_RUN on and read the log. Only flip it off once you agree with the matches, and export anything unmatched to a CSV for a human to look at instead of guessing.
Never attempt to write external_id or external_merchant_id on an existing order, BigCommerce will reject or ignore it. Always start with DRY_RUN=true, and only let a match write a real reconciliation tag when its confidence clears the threshold. Anything lower gets an UNMATCHED tag or a row in the manual review CSV, not a guess.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, scans the pre-cutover window, applies the pure decision function, and only ever writes staff_notes, respecting the dry run flag and the idempotency check every time.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Backfill a reconciliation key onto legacy BigCommerce orders, safely.
Orders placed before an ERP, marketplace, or order-management integration was
wired up were created without external_id, external_merchant_id, or
external_source, because those fields are only populated by whichever client
submits the order at creation time. BigCommerce treats external_merchant_id as
write-once (a PUT to change it returns a 400) and external_id behaves the same
way once the order already exists, so those fields cannot be safely rewritten
after the fact. This scans the pre-cutover window, matches each unmatched order
against an external export, and writes an idempotent reconciliation tag into
staff_notes instead, which stays mutable for the life of the order.
Run on a schedule or once per migration batch. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/backfill-order-metadata-for-matching/
"""
import os
import logging
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_order_metadata")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
HEADERS = {"X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json"}
MIGRATION_CUTOFF = os.environ.get("MIGRATION_CUTOFF", "2025-01-01T00:00:00+00:00")
CUTOVER_DATE = os.environ.get("CUTOVER_DATE", "2025-06-01T00:00:00+00:00")
MATCH_CONFIDENCE_THRESHOLD = float(os.environ.get("MATCH_CONFIDENCE_THRESHOLD", "0.8"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
VOID_STATUS_IDS = {0, 5, 6} # Incomplete, Cancelled, Declined
EXPECTED_SOURCE_TAGS = {"M-MIG"}
def bc_get(path, params=None):
r = requests.get(BASE + path, headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put(path, body):
r = requests.put(BASE + path, headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def needs_backfill(order):
if order.get("external_id"):
return False
if order.get("external_merchant_id"):
return False
source = order.get("external_source")
if source and source in EXPECTED_SOURCE_TAGS:
return False
return True
def decide_backfill_action(order, candidate_match, now_iso):
"""Pure decision logic. No I/O, fully testable with plain dicts."""
if order["status_id"] in VOID_STATUS_IDS:
return {"action": "skip", "reason": "incomplete_or_voided"}
existing_notes = order.get("staff_notes") or ""
if "[RECON:" in existing_notes:
return {"action": "skip", "reason": "already_tagged"}
if order.get("external_id") or order.get("external_merchant_id"):
return {"action": "skip", "reason": "already_has_external_key"}
if candidate_match is None or candidate_match.get("confidence", 0) < MATCH_CONFIDENCE_THRESHOLD:
return {
"action": "flag_unmatched",
"new_staff_notes": existing_notes + f"\n[RECON:UNMATCHED;checked={now_iso}]",
}
return {
"action": "write_staff_notes",
"new_staff_notes": existing_notes
+ f"\n[RECON:ext_id={candidate_match['external_id']};"
+ f"source={candidate_match.get('source', 'M-MIG')};matched={now_iso}]",
}
def scan_candidate_orders():
orders = bc_get("v2/orders", {
"min_date_created": MIGRATION_CUTOFF,
"max_date_created": CUTOVER_DATE,
"is_deleted": "false",
"sort": "id",
"limit": 250,
})
out = []
for stub in orders:
full = bc_get(f"v2/orders/{stub['id']}")
if needs_backfill(full):
out.append(full)
return out
def find_candidate_match(order, erp_export):
"""Match by customer_id, billing email, total, and a date tolerance window.
Replace with your real ERP export lookup. Returns None or a dict with
external_id, source, and confidence.
"""
return erp_export.get(order["id"])
def apply_staff_notes(order_id, new_staff_notes):
fresh = bc_get(f"v2/orders/{order_id}")
if "[RECON:" in (fresh.get("staff_notes") or ""):
return # another run already tagged it, idempotent no-op
return bc_put(f"v2/orders/{order_id}", {"staff_notes": new_staff_notes})
def run(erp_export=None):
erp_export = erp_export or {}
now_iso = datetime.now(timezone.utc).isoformat()
written = 0
unmatched = 0
skipped = 0
for order in scan_candidate_orders():
candidate_match = find_candidate_match(order, erp_export)
decision = decide_backfill_action(order, candidate_match, now_iso)
if decision["action"] == "skip":
skipped += 1
continue
log.info(
"Order %s -> %s. %s",
order["id"], decision["action"], "would write" if DRY_RUN else "writing",
)
if not DRY_RUN:
apply_staff_notes(order["id"], decision["new_staff_notes"])
if decision["action"] == "write_staff_notes":
written += 1
else:
unmatched += 1
log.info(
"Done. %d order(s) %s, %d flagged unmatched, %d skipped.",
written, "to reconcile" if DRY_RUN else "reconciled", unmatched, skipped,
)
if __name__ == "__main__":
run()
/**
* Backfill a reconciliation key onto legacy BigCommerce orders, safely.
*
* Orders placed before an ERP, marketplace, or order-management integration was
* wired up were created without external_id, external_merchant_id, or
* external_source, because those fields are only populated by whichever client
* submits the order at creation time. BigCommerce treats external_merchant_id as
* write-once (a PUT to change it returns a 400) and external_id behaves the same
* way once the order already exists, so those fields cannot be safely rewritten
* after the fact. This scans the pre-cutover window, matches each unmatched order
* against an external export, and writes an idempotent reconciliation tag into
* staff_notes instead, which stays mutable for the life of the order.
* Run on a schedule or once per migration batch. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/backfill-order-metadata-for-matching/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store-hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const HEADERS = { "X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json" };
const MIGRATION_CUTOFF = process.env.MIGRATION_CUTOFF || "2025-01-01T00:00:00+00:00";
const CUTOVER_DATE = process.env.CUTOVER_DATE || "2025-06-01T00:00:00+00:00";
const MATCH_CONFIDENCE_THRESHOLD = Number(process.env.MATCH_CONFIDENCE_THRESHOLD || 0.8);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const VOID_STATUS_IDS = new Set([0, 5, 6]); // Incomplete, Cancelled, Declined
const EXPECTED_SOURCE_TAGS = new Set(["M-MIG"]);
export function needsBackfill(order) {
if (order.external_id) return false;
if (order.external_merchant_id) return false;
const source = order.external_source;
if (source && EXPECTED_SOURCE_TAGS.has(source)) return false;
return true;
}
export function decideBackfillAction(order, candidateMatch, nowIso) {
if (VOID_STATUS_IDS.has(order.status_id)) {
return { action: "skip", reason: "incomplete_or_voided" };
}
const existingNotes = order.staff_notes || "";
if (existingNotes.includes("[RECON:")) {
return { action: "skip", reason: "already_tagged" };
}
if (order.external_id || order.external_merchant_id) {
return { action: "skip", reason: "already_has_external_key" };
}
if (!candidateMatch || (candidateMatch.confidence || 0) < MATCH_CONFIDENCE_THRESHOLD) {
return {
action: "flag_unmatched",
new_staff_notes: `${existingNotes}\n[RECON:UNMATCHED;checked=${nowIso}]`,
};
}
return {
action: "write_staff_notes",
new_staff_notes:
`${existingNotes}\n[RECON:ext_id=${candidateMatch.external_id};` +
`source=${candidateMatch.source || "M-MIG"};matched=${nowIso}]`,
};
}
async function bcGet(path, params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(BASE + path + (qs ? `?${qs}` : ""), { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(BASE + path, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function scanCandidateOrders() {
const orders = await bcGet("v2/orders", {
min_date_created: MIGRATION_CUTOFF,
max_date_created: CUTOVER_DATE,
is_deleted: "false",
sort: "id",
limit: 250,
});
const out = [];
for (const stub of orders) {
const full = await bcGet(`v2/orders/${stub.id}`);
if (needsBackfill(full)) out.push(full);
}
return out;
}
function findCandidateMatch(order, erpExport) {
return erpExport[order.id] || null;
}
async function applyStaffNotes(orderId, newStaffNotes) {
const fresh = await bcGet(`v2/orders/${orderId}`);
if ((fresh.staff_notes || "").includes("[RECON:")) {
return; // another run already tagged it, idempotent no-op
}
return bcPut(`v2/orders/${orderId}`, { staff_notes: newStaffNotes });
}
export async function run(erpExport = {}) {
const nowIso = new Date().toISOString();
let written = 0;
let unmatched = 0;
let skipped = 0;
for (const order of await scanCandidateOrders()) {
const candidateMatch = findCandidateMatch(order, erpExport);
const decision = decideBackfillAction(order, candidateMatch, nowIso);
if (decision.action === "skip") {
skipped++;
continue;
}
console.log(`Order ${order.id} -> ${decision.action}. ${DRY_RUN ? "would write" : "writing"}`);
if (!DRY_RUN) await applyStaffNotes(order.id, decision.new_staff_notes);
if (decision.action === "write_staff_notes") written++;
else unmatched++;
}
console.log(`Done. ${written} order(s) ${DRY_RUN ? "to reconcile" : "reconciled"}, ${unmatched} flagged unmatched, ${skipped} skipped.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether an order gets a real reconciliation key, an UNMATCHED flag, or is left alone. Because decide_backfill_action is pure, the test needs no network and no BigCommerce store. It just feeds in plain dicts and checks the answer.
from backfill_order_metadata import decide_backfill_action
NOW = "2026-07-10T00:00:00+00:00"
def order(**over):
base = {"status_id": 10, "staff_notes": "", "external_id": "", "external_merchant_id": ""}
base.update(over)
return base
def test_skips_incomplete_order():
result = decide_backfill_action(order(status_id=0), None, NOW)
assert result == {"action": "skip", "reason": "incomplete_or_voided"}
def test_skips_cancelled_order():
result = decide_backfill_action(order(status_id=5), {"external_id": "X", "confidence": 0.9}, NOW)
assert result["action"] == "skip"
assert result["reason"] == "incomplete_or_voided"
def test_skips_already_tagged_order():
result = decide_backfill_action(order(staff_notes="prior note\n[RECON:UNMATCHED;checked=x]"), None, NOW)
assert result == {"action": "skip", "reason": "already_tagged"}
def test_skips_order_with_existing_external_key():
result = decide_backfill_action(order(external_id="ERP-1"), {"external_id": "ERP-1", "confidence": 0.95}, NOW)
assert result == {"action": "skip", "reason": "already_has_external_key"}
def test_flags_unmatched_when_no_candidate():
result = decide_backfill_action(order(), None, NOW)
assert result["action"] == "flag_unmatched"
assert result["new_staff_notes"] == f"\n[RECON:UNMATCHED;checked={NOW}]"
def test_flags_unmatched_when_low_confidence():
result = decide_backfill_action(order(), {"external_id": "ERP-1", "confidence": 0.4}, NOW)
assert result["action"] == "flag_unmatched"
def test_writes_staff_notes_when_confident():
candidate = {"external_id": "ERP-00219482", "source": "M-MIG", "confidence": 0.92}
result = decide_backfill_action(order(), candidate, NOW)
assert result["action"] == "write_staff_notes"
assert result["new_staff_notes"] == f"\n[RECON:ext_id=ERP-00219482;source=M-MIG;matched={NOW}]"
def test_appends_rather_than_overwrites_existing_notes():
candidate = {"external_id": "ERP-9", "source": "M-MIG", "confidence": 0.85}
result = decide_backfill_action(order(staff_notes="called customer 2026-01-02"), candidate, NOW)
assert result["new_staff_notes"].startswith("called customer 2026-01-02\n[RECON:")
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideBackfillAction } from "./backfill-order-metadata.js";
const NOW = "2026-07-10T00:00:00.000Z";
const order = (over = {}) => ({
status_id: 10, staff_notes: "", external_id: "", external_merchant_id: "", ...over,
});
test("skips incomplete order", () => {
const result = decideBackfillAction(order({ status_id: 0 }), null, NOW);
assert.deepEqual(result, { action: "skip", reason: "incomplete_or_voided" });
});
test("skips cancelled order even with a confident match", () => {
const result = decideBackfillAction(order({ status_id: 5 }), { external_id: "X", confidence: 0.9 }, NOW);
assert.equal(result.action, "skip");
assert.equal(result.reason, "incomplete_or_voided");
});
test("skips already tagged order", () => {
const result = decideBackfillAction(order({ staff_notes: "prior note\n[RECON:UNMATCHED;checked=x]" }), null, NOW);
assert.deepEqual(result, { action: "skip", reason: "already_tagged" });
});
test("skips order with existing external key", () => {
const result = decideBackfillAction(order({ external_id: "ERP-1" }), { external_id: "ERP-1", confidence: 0.95 }, NOW);
assert.deepEqual(result, { action: "skip", reason: "already_has_external_key" });
});
test("flags unmatched when no candidate", () => {
const result = decideBackfillAction(order(), null, NOW);
assert.equal(result.action, "flag_unmatched");
assert.equal(result.new_staff_notes, `\n[RECON:UNMATCHED;checked=${NOW}]`);
});
test("flags unmatched when confidence is low", () => {
const result = decideBackfillAction(order(), { external_id: "ERP-1", confidence: 0.4 }, NOW);
assert.equal(result.action, "flag_unmatched");
});
test("writes staff notes when confident", () => {
const candidate = { external_id: "ERP-00219482", source: "M-MIG", confidence: 0.92 };
const result = decideBackfillAction(order(), candidate, NOW);
assert.equal(result.action, "write_staff_notes");
assert.equal(result.new_staff_notes, `\n[RECON:ext_id=ERP-00219482;source=M-MIG;matched=${NOW}]`);
});
test("appends rather than overwrites existing notes", () => {
const candidate = { external_id: "ERP-9", source: "M-MIG", confidence: 0.85 };
const result = decideBackfillAction(order({ staff_notes: "called customer 2026-01-02" }), candidate, NOW);
assert.ok(result.new_staff_notes.startsWith("called customer 2026-01-02\n[RECON:"));
});
Case studies
Three years of orders with nothing to join on
A hardware distributor connected an ERP integration in year four of running BigCommerce. Every order from the first three years had no external_id, and finance wanted every one of them reconciled against the ERP's own order ledger for an audit.
The team ran the scan across the whole pre-cutover window, matched by customer_id and total within a two day window, and let the script write staff_notes tags at high confidence. About 6% came back low confidence and landed in a manual review CSV instead of getting a guessed key, which is exactly what the auditors wanted to see.
A failed PUT taught them not to fight external_id
A team building a new marketplace connector assumed they could just PUT external_id onto the old orders once the connector was live. The first attempt came back with a 400 on external_merchant_id, and a few external_id writes were silently ignored on others.
Once they stopped trying to rewrite those fields and switched to a staff_notes tag, the backlog cleared in one afternoon, and the re-GET idempotency check meant re-running the job after a partial failure never double tagged anything.
After this runs, every legacy order either carries a durable [RECON: key your reconciliation job can grep for, or an honest UNMATCHED flag that routes to a human instead of a guess. Nothing fought BigCommerce's write-once fields, nothing got tagged twice, and abandoned or voided orders never picked up a key they did not need.
FAQ
Can I set external_id or external_merchant_id on an order that already exists?
Not reliably. BigCommerce treats external_merchant_id as write-once, so a PUT that tries to change it after it is first set returns a 400. Support threads also show external_id being ignored or rejected when a PUT tries to add it after order creation, because both fields are meant to be set by the client that submits the order at POST time.
What should I write to legacy orders instead of external_id?
Write a structured tag into staff_notes, a control panel only field that stays mutable for the life of the order. A tag like [RECON:ext_id=ERP-00219482;source=M-MIG;matched=2026-07-10] gives your reconciliation job a durable, greppable key without touching the fields BigCommerce will not let you rewrite.
How do I avoid tagging an order twice or tagging an abandoned order?
Re-GET the order right before you write and skip it if staff_notes already contains a [RECON: block, which makes the job idempotent. Also skip any order whose status_id is 0 (Incomplete), 5 (Cancelled), or 6 (Declined), since those were never real sales and should not carry a reconciliation key.
Related field notes
Citations
On the problem:
- BigCommerce Support: update external_id on order using V2 Orders API. support.bigcommerce.com update external_id on order
- BigCommerce Support: update the external_id field through the orders V2 API. support.bigcommerce.com update the external_id field
- BigCommerce Developer Center: Orders Overview. developer.bigcommerce.com/docs/store-operations/orders
On the solution:
- BigCommerce Docs: Update Order, REST Management API reference. docs.bigcommerce.com update-order
- BigCommerce Docs: Get Order, REST Management API reference. docs.bigcommerce.com get-order
- BigCommerce Docs: List Orders, REST Management API reference. docs.bigcommerce.com get-orders
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, catalog, or store migrations 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 your reconciliation backlog?
If this saved you from fighting a write-once field or guessing at a match, 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