Reconciler Orders and payments
BigCommerce orders stuck on Manual Verification Required, and never cleared
A fraud-screening app flags an order for review, BigCommerce parks it at status_id 12, and a person then approves it inside that app's own dashboard. Nothing ever tells BigCommerce the review passed, so the order sits at Manual Verification Required forever, blocked from fulfillment, while the human approval it is waiting on already happened somewhere else. Here is why that gap exists and a small script that finds the orders a human already cleared and moves just those, one at a time.
Orders land on status_id 12 (Manual Verification Required) when a fraud tool like FraudLabs Pro, NoFraud, Signifyd, or Kount, or an ERP connector like Acumatica, writes back a REVIEW verdict through the Orders API. The actual human review happens in that app's dashboard, not in BigCommerce, so there is no automatic trigger that clears the order once someone approves it. Run a small Python or Node.js script that lists orders on status_id 12, keeps only the ones with an explicit approval marker in staff_notes or order messages and a non-declined transaction, and moves just that reviewed batch to status_id 11 (Awaiting Fulfillment) one order at a time. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce has an order status built for exactly this moment: Manual Verification Required, status_id 12. A fraud-screening app scores the order, decides it needs a second look, and writes that status back onto the order through the Orders API. So far, everything is working as designed.
The trouble starts after that. The actual review, the part where a real person looks at the order and decides it is fine, happens inside the fraud app's own dashboard, not inside BigCommerce. FraudLabs Pro, NoFraud, Signifyd, and Kount all keep their own review queue and their own approve button. When a staff member clicks approve there, that decision lives in the third-party tool. Nothing built into BigCommerce watches for that approval and flips the order's status_id back to something fulfillable. The order just sits at 12, approved in spirit, blocked in practice.
Why it happens
BigCommerce's own status list simply does not include a step for "third party said yes." A few things line up to create the gap:
- Fraud apps like FraudLabs Pro, NoFraud, Signifyd, and Kount write
status_id 12to an order the moment their check returns a REVIEW verdict, using the same Orders API a merchant would use. - ERP or order-management connectors, such as an Acumatica integration, can push the same status when their own workflow flags an order for a manual check before it syncs.
- The review and the approve action both happen in that external tool's dashboard, which has no reason to call back into BigCommerce once a person clicks approve there.
- There is no BigCommerce webhook or automation that fires on "fraud app said this order is clean," so nothing is listening for the moment it becomes safe to move on.
Merchants end up reviewing and approving the order correctly, just in the wrong place, and never get an automated way to reflect that back onto the BigCommerce order. See the Manual Verification Status article and the seller community thread in the citations for how BigCommerce documents this status and what merchants report.
Manual Verification exists specifically to gate potentially fraudulent orders, so a script must never treat elapsed time as permission to clear one. The only thing that should ever move an order off status_id 12 is proof that a human already reviewed it, and that proof has to come from inside BigCommerce itself, an approval marker written into staff_notes or into an order message. A script can surface those orders. It cannot manufacture the approval.
The fix, as a flow
We never auto-transition an order just because it has been sitting at 12 for a while. Instead the job lists every order on status_id 12, reads its notes and messages for a marker such as APPROVED, Cleared by, or Verified by, cross-checks the gateway transaction so a declined or voided payment can never slip through, and reports the ones that qualify. Only when a human has looked at that report and confirmed it does the script, run again with writing turned on, move each approved order to Awaiting Fulfillment and leave an audit trail behind.
Build it step by step
Get a BigCommerce API token
Create an API account under Settings, API, Store-level API accounts, with the Orders scope set to modify. Keep the store hash and access token in environment variables, never in the file. Every request goes through the X-Auth-Token header against your store's base URL.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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="xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V2 Orders API
A small helper sends the request with the auth header and raises on a bad status. We reuse it for listing orders, reading a single order, reading transactions, reading messages, and later for the write.
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}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
List the orders sitting on Manual Verification Required
Page through GET /v2/orders?status_id=12 with page and limit, and add min_date_modified when you want an incremental run instead of scanning the whole backlog every time.
def orders_pending_verification(min_date_modified=None):
page = 1
while True:
params = {"status_id": 12, "limit": 250, "page": page}
if min_date_modified:
params["min_date_modified"] = min_date_modified
batch = bc("GET", "/v2/orders", params=params) or []
if not batch:
return
for order in batch:
yield order
if len(batch) < 250:
return
page += 1
async function* ordersPendingVerification(minDateModified) {
let page = 1;
while (true) {
const qs = new URLSearchParams({ status_id: "12", limit: "250", page: String(page) });
if (minDateModified) qs.set("min_date_modified", minDateModified);
const batch = (await bc("GET", `/v2/orders?${qs.toString()}`)) || [];
if (batch.length === 0) return;
for (const order of batch) yield order;
if (batch.length < 250) return;
page += 1;
}
}
Decide, with one pure function
Keep the branching in its own function that takes the order, its messages, and the transaction status, and returns "clear", "hold", or "skip". It skips anything not on status_id 12, holds anything with a declined or voided transaction, and only clears an order when staff_notes or a message carries an approval marker like APPROVED, Cleared by, or Verified by, timestamped after the order's date_modified. This is the part worth testing, so we keep it free of any network call.
import re
from datetime import datetime
from email.utils import parsedate_to_datetime
APPROVAL_RE = re.compile(r"\b(approved|cleared|verified)\b", re.IGNORECASE)
DECLINED_TXN_STATUSES = {"declined", "void"}
OK_TXN_STATUSES = {None, "approved", "captured"}
def _parse_date(value):
if not value:
return None
try:
return parsedate_to_datetime(value)
except (TypeError, ValueError):
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def decide_clearable(order, messages, transaction_status):
if order.get("status_id") != 12:
return "skip"
if transaction_status in DECLINED_TXN_STATUSES:
return "hold"
modified_at = _parse_date(order.get("date_modified"))
has_marker = False
note = order.get("staff_notes") or ""
if APPROVAL_RE.search(note):
has_marker = True
if not has_marker:
for msg in messages or []:
text = msg.get("message") or ""
if not APPROVAL_RE.search(text):
continue
created_at = _parse_date(msg.get("date_created"))
if modified_at is None or created_at is None or created_at >= modified_at:
has_marker = True
break
if has_marker and transaction_status in OK_TXN_STATUSES:
return "clear"
return "hold"
const APPROVAL_RE = /\b(approved|cleared|verified)\b/i;
const DECLINED_TXN_STATUSES = new Set(["declined", "void"]);
const OK_TXN_STATUSES = new Set([null, undefined, "approved", "captured"]);
function parseDate(value) {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
export function decideClearable(order, messages, transactionStatus) {
if (order.status_id !== 12) return "skip";
if (DECLINED_TXN_STATUSES.has(transactionStatus)) return "hold";
const modifiedAt = parseDate(order.date_modified);
let hasMarker = false;
const note = order.staff_notes || "";
if (APPROVAL_RE.test(note)) hasMarker = true;
if (!hasMarker) {
for (const msg of messages || []) {
const text = msg.message || "";
if (!APPROVAL_RE.test(text)) continue;
const createdAt = parseDate(msg.date_created);
if (modifiedAt === null || createdAt === null || createdAt >= modifiedAt) {
hasMarker = true;
break;
}
}
}
if (hasMarker && OK_TXN_STATUSES.has(transactionStatus)) return "clear";
return "hold";
}
Read the transaction status before trusting any marker
GET /v2/orders/{id}/transactions tells you whether the gateway ever declined or voided the charge. Reduce that list to a single status the decision function can read, so an approval marker never overrides a payment that actually failed.
def transaction_status_for(order_id):
txns = bc("GET", f"/v2/orders/{order_id}/transactions") or []
for t in txns:
status = (t.get("status") or "").lower()
if status in {"declined", "void"}:
return status
for t in txns:
status = (t.get("status") or "").lower()
if status in {"approved", "captured"}:
return status
return None
async function transactionStatusFor(orderId) {
const txns = (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
for (const t of txns) {
const status = (t.status || "").toLowerCase();
if (status === "declined" || status === "void") return status;
}
for (const t of txns) {
const status = (t.status || "").toLowerCase();
if (status === "approved" || status === "captured") return status;
}
return null;
}
Clear the order and leave an audit trail, only when confirmed
When a run has DRY_RUN=false and the batch has been confirmed by a human, move a cleared order with PUT /v2/orders/{id} setting status_id: 11, one order at a time, and record the transition with POST /v2/orders/{id}/messages so there is a trace of what the script did and why.
Always start with DRY_RUN=true and read the report before writing anything. Never bulk-flip status_id 12 orders based on elapsed time alone. Only orders with an explicit human-approval marker and a non-declined transaction are candidates, and only a human decides when to run the batch for real.
The full code
Here is the complete script in one file for each language. It lists status_id 12 orders, decides clear, hold, or skip with the pure function, and only writes when DRY_RUN is off, moving each cleared order to Awaiting Fulfillment with an audit message.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Surface and clear BigCommerce orders stuck on Manual Verification Required.
A fraud-screening app (FraudLabs Pro, NoFraud, Signifyd, Kount) or an ERP
connector writes status_id 12 to an order when it flags a REVIEW verdict.
The human review then happens inside that app's own dashboard, so nothing
tells BigCommerce the order was approved. This never auto-transitions an
order on elapsed time. It only reports orders that already carry an explicit
human-approval marker in staff_notes or messages, with a non-declined
transaction, and only writes when DRY_RUN=false. Safe to run again and again.
"""
import os
import re
import logging
import requests
from datetime import datetime
from email.utils import parsedate_to_datetime
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("clear_manual_verification")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
MIN_DATE_MODIFIED = os.environ.get("MIN_DATE_MODIFIED")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
MANUAL_VERIFICATION_STATUS_ID = 12
AWAITING_FULFILLMENT_STATUS_ID = 11
APPROVAL_RE = re.compile(r"\b(approved|cleared|verified)\b", re.IGNORECASE)
DECLINED_TXN_STATUSES = {"declined", "void"}
OK_TXN_STATUSES = {None, "approved", "captured"}
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
def _parse_date(value):
if not value:
return None
try:
return parsedate_to_datetime(value)
except (TypeError, ValueError):
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def decide_clearable(order, messages, transaction_status):
"""Pure decision: 'clear', 'hold', or 'skip'. No I/O, easy to unit test."""
if order.get("status_id") != MANUAL_VERIFICATION_STATUS_ID:
return "skip"
if transaction_status in DECLINED_TXN_STATUSES:
return "hold"
modified_at = _parse_date(order.get("date_modified"))
has_marker = False
note = order.get("staff_notes") or ""
if APPROVAL_RE.search(note):
has_marker = True
if not has_marker:
for msg in messages or []:
text = msg.get("message") or ""
if not APPROVAL_RE.search(text):
continue
created_at = _parse_date(msg.get("date_created"))
if modified_at is None or created_at is None or created_at >= modified_at:
has_marker = True
break
if has_marker and transaction_status in OK_TXN_STATUSES:
return "clear"
return "hold"
def orders_pending_verification():
page = 1
while True:
params = {"status_id": MANUAL_VERIFICATION_STATUS_ID, "limit": 250, "page": page}
if MIN_DATE_MODIFIED:
params["min_date_modified"] = MIN_DATE_MODIFIED
batch = bc("GET", "/v2/orders", params=params) or []
if not batch:
return
for order in batch:
yield order
if len(batch) < 250:
return
page += 1
def order_messages(order_id):
return bc("GET", f"/v2/orders/{order_id}/messages") or []
def transaction_status_for(order_id):
txns = bc("GET", f"/v2/orders/{order_id}/transactions") or []
for t in txns:
status = (t.get("status") or "").lower()
if status in DECLINED_TXN_STATUSES:
return status
for t in txns:
status = (t.get("status") or "").lower()
if status in {"approved", "captured"}:
return status
return None
def clear_order(order_id):
bc("PUT", f"/v2/orders/{order_id}", json={"status_id": AWAITING_FULFILLMENT_STATUS_ID})
bc("POST", f"/v2/orders/{order_id}/messages", json={
"message": "Cleared Manual Verification Required to Awaiting Fulfillment "
"after finding an explicit staff approval marker and a non-declined transaction.",
"status_id": AWAITING_FULFILLMENT_STATUS_ID,
})
def run():
cleared = 0
held = 0
for order in orders_pending_verification():
order_id = order["id"]
full_order = bc("GET", f"/v2/orders/{order_id}") or order
messages = order_messages(order_id)
txn_status = transaction_status_for(order_id)
decision = decide_clearable(full_order, messages, txn_status)
if decision == "skip":
continue
if decision == "hold":
held += 1
continue
log.info("Order %s clearable. %s", order_id, "would clear" if DRY_RUN else "clearing")
if not DRY_RUN:
clear_order(order_id)
cleared += 1
log.info("Done. %d order(s) %s, %d held for review.",
cleared, "to clear" if DRY_RUN else "cleared", held)
if __name__ == "__main__":
run()
/**
* Surface and clear BigCommerce orders stuck on Manual Verification Required.
*
* A fraud-screening app (FraudLabs Pro, NoFraud, Signifyd, Kount) or an ERP
* connector writes status_id 12 to an order when it flags a REVIEW verdict.
* The human review then happens inside that app's own dashboard, so nothing
* tells BigCommerce the order was approved. This never auto-transitions an
* order on elapsed time. It only reports orders that already carry an
* explicit human-approval marker in staff_notes or messages, with a
* non-declined transaction, and only writes when DRY_RUN=false.
* Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/manual-verification-required-never-cleared/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const MIN_DATE_MODIFIED = process.env.MIN_DATE_MODIFIED || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const MANUAL_VERIFICATION_STATUS_ID = 12;
const AWAITING_FULFILLMENT_STATUS_ID = 11;
const APPROVAL_RE = /\b(approved|cleared|verified)\b/i;
const DECLINED_TXN_STATUSES = new Set(["declined", "void"]);
const OK_TXN_STATUSES = new Set([null, undefined, "approved", "captured"]);
function parseDate(value) {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
export function decideClearable(order, messages, transactionStatus) {
if (order.status_id !== MANUAL_VERIFICATION_STATUS_ID) return "skip";
if (DECLINED_TXN_STATUSES.has(transactionStatus)) return "hold";
const modifiedAt = parseDate(order.date_modified);
let hasMarker = false;
const note = order.staff_notes || "";
if (APPROVAL_RE.test(note)) hasMarker = true;
if (!hasMarker) {
for (const msg of messages || []) {
const text = msg.message || "";
if (!APPROVAL_RE.test(text)) continue;
const createdAt = parseDate(msg.date_created);
if (modifiedAt === null || createdAt === null || createdAt >= modifiedAt) {
hasMarker = true;
break;
}
}
}
if (hasMarker && OK_TXN_STATUSES.has(transactionStatus)) return "clear";
return "hold";
}
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function* ordersPendingVerification() {
let page = 1;
while (true) {
const qs = new URLSearchParams({ status_id: String(MANUAL_VERIFICATION_STATUS_ID), limit: "250", page: String(page) });
if (MIN_DATE_MODIFIED) qs.set("min_date_modified", MIN_DATE_MODIFIED);
const batch = (await bc("GET", `/v2/orders?${qs.toString()}`)) || [];
if (batch.length === 0) return;
for (const order of batch) yield order;
if (batch.length < 250) return;
page += 1;
}
}
async function orderMessages(orderId) {
return (await bc("GET", `/v2/orders/${orderId}/messages`)) || [];
}
async function transactionStatusFor(orderId) {
const txns = (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
for (const t of txns) {
const status = (t.status || "").toLowerCase();
if (DECLINED_TXN_STATUSES.has(status)) return status;
}
for (const t of txns) {
const status = (t.status || "").toLowerCase();
if (status === "approved" || status === "captured") return status;
}
return null;
}
async function clearOrder(orderId) {
await bc("PUT", `/v2/orders/${orderId}`, { status_id: AWAITING_FULFILLMENT_STATUS_ID });
await bc("POST", `/v2/orders/${orderId}/messages`, {
message: "Cleared Manual Verification Required to Awaiting Fulfillment "
+ "after finding an explicit staff approval marker and a non-declined transaction.",
status_id: AWAITING_FULFILLMENT_STATUS_ID,
});
}
export async function run() {
let cleared = 0;
let held = 0;
for await (const order of ordersPendingVerification()) {
const orderId = order.id;
const fullOrder = (await bc("GET", `/v2/orders/${orderId}`)) || order;
const messages = await orderMessages(orderId);
const txnStatus = await transactionStatusFor(orderId);
const decision = decideClearable(fullOrder, messages, txnStatus);
if (decision === "skip") continue;
if (decision === "hold") { held++; continue; }
console.log(`Order ${orderId} clearable. ${DRY_RUN ? "would clear" : "clearing"}`);
if (!DRY_RUN) await clearOrder(orderId);
cleared++;
}
console.log(`Done. ${cleared} order(s) ${DRY_RUN ? "to clear" : "cleared"}, ${held} held for review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which real order gets moved off Manual Verification Required. Since decide_clearable is pure, no network and no BigCommerce store are needed. It just feeds in plain objects and checks the verdict.
from clear_manual_verification import decide_clearable
def order(**over):
base = {"status_id": 12, "staff_notes": "", "date_modified": "2026-07-01T10:00:00Z"}
base.update(over)
return base
def msg(text, created_at="2026-07-02T10:00:00Z"):
return {"message": text, "date_created": created_at}
def test_skip_when_not_status_12():
assert decide_clearable(order(status_id=11), [], "captured") == "skip"
def test_hold_when_transaction_declined():
assert decide_clearable(order(staff_notes="Approved by Jane"), [], "declined") == "hold"
def test_hold_when_transaction_void():
assert decide_clearable(order(staff_notes="Cleared by Jane"), [], "void") == "hold"
def test_hold_when_no_approval_marker():
assert decide_clearable(order(), [], "captured") == "hold"
def test_clear_when_staff_notes_has_marker_and_transaction_ok():
assert decide_clearable(order(staff_notes="Verified by Jane on review"), [], "captured") == "clear"
def test_clear_when_message_marker_after_date_modified():
result = decide_clearable(order(), [msg("Approved after review")], "approved")
assert result == "clear"
def test_hold_when_message_marker_before_date_modified():
result = decide_clearable(
order(date_modified="2026-07-05T10:00:00Z"),
[msg("Approved earlier", created_at="2026-07-01T09:00:00Z")],
"captured",
)
assert result == "hold"
def test_clear_when_transaction_status_is_none():
assert decide_clearable(order(staff_notes="Cleared by Jane"), [], None) == "clear"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideClearable } from "./clear-manual-verification.js";
const order = (over = {}) => ({ status_id: 12, staff_notes: "", date_modified: "2026-07-01T10:00:00Z", ...over });
const msg = (text, createdAt = "2026-07-02T10:00:00Z") => ({ message: text, date_created: createdAt });
test("skip when not status 12", () => {
assert.equal(decideClearable(order({ status_id: 11 }), [], "captured"), "skip");
});
test("hold when transaction declined", () => {
assert.equal(decideClearable(order({ staff_notes: "Approved by Jane" }), [], "declined"), "hold");
});
test("hold when transaction void", () => {
assert.equal(decideClearable(order({ staff_notes: "Cleared by Jane" }), [], "void"), "hold");
});
test("hold when no approval marker", () => {
assert.equal(decideClearable(order(), [], "captured"), "hold");
});
test("clear when staff_notes has marker and transaction ok", () => {
assert.equal(decideClearable(order({ staff_notes: "Verified by Jane on review" }), [], "captured"), "clear");
});
test("clear when message marker after date_modified", () => {
assert.equal(decideClearable(order(), [msg("Approved after review")], "approved"), "clear");
});
test("hold when message marker before date_modified", () => {
const result = decideClearable(
order({ date_modified: "2026-07-05T10:00:00Z" }),
[msg("Approved earlier", "2026-07-01T09:00:00Z")],
"captured",
);
assert.equal(result, "hold");
});
test("clear when transaction status is null", () => {
assert.equal(decideClearable(order({ staff_notes: "Cleared by Jane" }), [], null), "clear");
});
Case studies
The store with a growing review queue
A home goods store ran FraudLabs Pro on every order. Anything scored borderline landed on Manual Verification Required, and staff reviewed and approved most of them inside the FraudLabs Pro dashboard within the hour. But BigCommerce never heard about the approval, so those orders piled up at status_id 12 next to the ones still genuinely waiting on review, and nobody could tell the two apart at a glance.
Now staff add a one line note, "Approved by Priya," in the order the moment they clear it in FraudLabs Pro. The script runs in dry run every morning, lists exactly the orders that now carry that marker with a captured transaction, and a manager confirms the short list before the real run moves them to Awaiting Fulfillment.
The ERP sync that kept flagging shipped-ready orders
A distributor synced BigCommerce orders into Acumatica, and Acumatica's own workflow occasionally pushed status_id 12 back onto orders that needed a manual credit check. Once finance cleared the credit check in Acumatica, the BigCommerce order stayed parked, and warehouse staff had no idea it was safe to pick and pack.
The team added an order message, "Verified by finance," as part of the existing Acumatica clearance step, so no new manual work was created. The script then finds those orders daily, holds anything with a declined transaction no matter what the note says, and only clears the rest after someone on the ops team signs off on the report.
After this runs on a schedule, Manual Verification Required stops being a black hole. Orders that were genuinely approved by a human, in the fraud tool or the ERP, get surfaced automatically and move to Awaiting Fulfillment with a clear audit trail, while anything without a real approval marker, or with a declined transaction, stays exactly where it is for a person to look at. Fraud protection stays intact, and cleared orders stop rotting in limbo.
FAQ
Why do BigCommerce orders get stuck on Manual Verification Required?
A fraud-screening app such as FraudLabs Pro, NoFraud, Signifyd, or Kount, or an ERP connector such as Acumatica, writes status_id 12 back to the order when its check returns a REVIEW verdict. The human review then happens inside that app's own dashboard, not in BigCommerce, so there is no built-in trigger that moves the order out of status_id 12 once a person approves it there.
Is it safe to auto-clear orders stuck on Manual Verification Required?
No, not automatically. Manual Verification exists specifically to gate potentially fraudulent orders, so a script should never flip status_id based on elapsed time alone. It is safe to have a script surface only the orders that already carry an explicit human-approval marker in staff_notes or order messages, with a non-declined transaction, and then move just that reviewed batch with a human confirming the run.
What BigCommerce status_id should a cleared order move to?
Move it to status_id 11, Awaiting Fulfillment, with a PUT to /v2/orders/{id}. That is the same state a normal paid order reaches once payment clears, so the order continues down the regular fulfillment path from there.
Related field notes
Citations
On the problem:
- BigCommerce Support: Manual Verification Status. support.bigcommerce.com manual verification status
- BigCommerce Support Community: what does manual verification mean to me as a seller. support.bigcommerce.com manual verification seller question
- BigCommerce Help Center: Order Statuses. support.bigcommerce.com order statuses
On the solution:
- BigCommerce Developer Center: Orders, REST Management API Overview. developer.bigcommerce.com rest-management orders
- BigCommerce Developer Center: Order Status. developer.bigcommerce.com rest-management orders order-status
- BigCommerce API Reference: List Order Messages. docs.bigcommerce.com order-messages get-order-messages
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear your review queue?
If this saved you a pile of manual clicks or a stuck fulfillment queue, 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