Diagnostic Pricing / Price Lists

Order created via API bypasses customer group and price list pricing

A wholesale customer has a price list assigned to their customer group and gets the right discount at storefront checkout every time. Then an integration creates the same customer's order through the API, and the invoice comes back at full retail. POST /v2/orders is a back-office order-entry endpoint, not the checkout pricing engine, and when the caller hands it a line price it trusts that number completely. Here is why that gap opens up and a script that finds every order billed at the wrong price.

Python and Node.js BigCommerce V2 Orders + V3 Price Lists Safe by default (dry run, flag only)
Two people working in a cluttered store.
Photo by Phuong Nguyen on Unsplash
The short answer

BigCommerce's V2 Create Order endpoint, POST /v2/orders, does not run a cart through the pricing service unless the caller omits price fields entirely. When an integration supplies price_inc_tax/price_ex_tax on each line, BigCommerce takes that number as authoritative and skips resolving it against the customer's assigned Price List or customer-group discount rules. There is no cart_id on the order, so BigCommerce has no signal the submitted price is stale or unresolved. Run a small Python or Node.js script that pulls each customer's assigned price list, compares the billed line price against the price-list record and the plain catalog price, and flags any order billed at catalog price when a price list should have applied. Full code, tests, and a dry run guard are below.

The problem in plain words

In BigCommerce, storefront checkout and POST /v2/orders are two different paths to the same table. Checkout always runs a cart through the pricing service, which looks up the shopper's customer group, checks for an assigned Price List, and resolves the correct price_ex_tax for every line before the order is ever written. The V2 orders API was built for a different job: back-office order entry, where a merchant or an integration is recreating an order that was already priced somewhere else, over the phone, in an ERP, in a spreadsheet.

BigCommerce's own docs warn that overriding one price field without the other breaks total calculation, so many integrations defensively supply both price_inc_tax and price_ex_tax on every line, having "pre-resolved" the price client-side. That is exactly the condition that makes the API skip pricing resolution entirely. The order has no cart_id tying it back to a priced cart, so there is nothing for BigCommerce to check the submitted number against. It trusts the integration's math. If that math used the plain catalog price instead of looking up the customer's price list, the order is wrong, and nothing about the order record itself tells you that happened.

Integration reads catalog price client-side POST /v2/orders price_ex_tax supplied on each line item No cart_id, no check Price list and group discount never checked Billed at retail price
The pricing service only runs when there is a priced cart behind the order. Supply the price fields yourself, and BigCommerce never looks up the customer's price list.

Why it happens

BigCommerce resolves customer-group and price-list pricing at the cart and checkout layer, not as a rule the order-write path re-checks on every save. A few common ways stores end up with API-created orders billed at the wrong price:

The classic symptom is a B2B or wholesale customer being billed retail catalog price on an API-created order, even though the exact same customer gets the correct discounted price at storefront checkout the very next day. See the citations at the end for the exact docs describing this order-entry versus checkout split.

The key insight

The line price on an API-created order is not proof of anything. The customer's assigned price list is. So the safe pattern is not "trust price_ex_tax on the order." It is "look up the customer's customer_group_id, find their assigned price list with GET /v3/pricelists/assignments?customer_group_id=, pull the record price with GET /v3/pricelists/{id}/records, and compare that against what the order actually billed." Anything that matches the plain catalog price instead of the price-list record, when a price list should have applied, gets flagged, never silently rewritten.

The fix, as a flow

We do not touch the live order-write path or the integration's own logic. We add a scan that walks recent orders, resolves each customer's price list, and decides per order line whether the billed price matches the price list, matches plain catalog price instead, or matches neither.

Scheduled job recent orders window Resolve customer group, price list id Pull record price vs. catalog, billed Billed equals catalog, not list? yes no, correctly priced Flag with delta cancel or refund note, never auto-fix
The scan only flags a line when the billed price matches plain catalog price while a price list existed and disagreed. Everything else is left alone, and every flagged line gets a report entry, never a silent rewrite.

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 (read), Customers (read), and Pricing/Price Lists (read) scope. 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 LOOKBACK_DAYS="14"
export CHANNEL_ID="1"
export DRY_RUN="true"   # start safe, this script only ever reports
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="14"
export CHANNEL_ID="1"
export DRY_RUN="true"   // start safe, this script only ever reports
2

Talk to the V2 Orders and V3 REST APIs

Orders and their line items live under https://api.bigcommerce.com/stores/{store_hash}/v2/. Customers, price lists, and the catalog live under the /v3/ base and wrap results in {data, meta.pagination}. Both use the same X-Auth-Token header. A small pair of helpers handles GET for each base and raises on a non-2xx response.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
V2_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
V3_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get_v2(path, params=None):
    r = requests.get(f"{V2_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []

def bc_get_v3(path, params=None):
    r = requests.get(f"{V3_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {"data": []}
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const V2_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const V3_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGetV2(path, params = {}) {
  const url = new URL(`${V2_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 bcGetV3(path, params = {}) {
  const url = new URL(`${V3_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) : { data: [] };
}
3

Resolve the customer's price list and record price

For each order's customer_id, call GET /v3/customers?id:in={customer_id} to read customer_group_id. Then call GET /v3/pricelists/assignments?customer_group_id={id}&channel_id={id} to get the assigned price_list_id. If there is no assignment, skip the order entirely, since there is no price list to bypass. Otherwise pull the price-list record for each variant with GET /v3/pricelists/{price_list_id}/records?variant_id:in={ids}, and the plain catalog price with GET /v3/catalog/products/{product_id}/variants/{variant_id}.

step3.py
def customer_group_id(customer_id):
    data = bc_get_v3("/customers", {"id:in": customer_id})
    rows = data.get("data") or []
    return rows[0]["customer_group_id"] if rows else None

def assigned_price_list_id(customer_group_id_, channel_id):
    data = bc_get_v3("/pricelists/assignments", {
        "customer_group_id": customer_group_id_,
        "channel_id": channel_id,
    })
    rows = data.get("data") or []
    return rows[0]["price_list_id"] if rows else None

def price_list_record_price(price_list_id, variant_id):
    data = bc_get_v3(f"/pricelists/{price_list_id}/records", {"variant_id:in": variant_id})
    rows = data.get("data") or []
    return rows[0].get("price_ex_tax") if rows else None

def catalog_variant_price(product_id, variant_id):
    data = bc_get_v3(f"/catalog/products/{product_id}/variants/{variant_id}")
    row = data.get("data") or {}
    return row.get("price")
step3.js
async function customerGroupId(customerId) {
  const data = await bcGetV3("/customers", { "id:in": customerId });
  const rows = data.data || [];
  return rows.length ? rows[0].customer_group_id : null;
}

async function assignedPriceListId(customerGroupIdValue, channelId) {
  const data = await bcGetV3("/pricelists/assignments", {
    customer_group_id: customerGroupIdValue,
    channel_id: channelId,
  });
  const rows = data.data || [];
  return rows.length ? rows[0].price_list_id : null;
}

async function priceListRecordPrice(priceListId, variantId) {
  const data = await bcGetV3(`/pricelists/${priceListId}/records`, { "variant_id:in": variantId });
  const rows = data.data || [];
  return rows.length ? rows[0].price_ex_tax : null;
}

async function catalogVariantPrice(productId, variantId) {
  const data = await bcGetV3(`/catalog/products/${productId}/variants/${variantId}`);
  const row = data.data || {};
  return row.price;
}
4

Decide, with one pure function

Keep the decision in its own function that takes only plain values: whether the customer has an assigned price list, what the price-list record says, what plain catalog price says, what the order actually billed, the order's status_id, and whether a transaction has already been captured. It returns a flag decision and, when flagged, a recommended action. All comparisons run on decimal strings via Decimal, never floats, because these are money.

diagnose.py
from decimal import Decimal

UNPAID_STATUS_IDS = {0, 7, 11}

def diagnose_order_line_pricing(
    customer_group_id,
    assigned_price_list_id,
    price_list_record_price_ex_tax,
    catalog_price_ex_tax,
    billed_price_ex_tax,
    order_status_id,
    has_captured_transaction,
):
    if assigned_price_list_id is None or price_list_record_price_ex_tax is None:
        return {"flagged": False, "reason": "no_price_list_assigned", "delta_ex_tax": "0", "recommended_action": "none"}

    list_price = Decimal(price_list_record_price_ex_tax)
    billed = Decimal(billed_price_ex_tax)
    catalog = Decimal(catalog_price_ex_tax)

    if billed == list_price:
        return {"flagged": False, "reason": "correctly_priced", "delta_ex_tax": "0", "recommended_action": "none"}

    unpaid = order_status_id in UNPAID_STATUS_IDS and not has_captured_transaction
    action = "cancel_unpaid" if unpaid else "report_refund_delta"

    if billed == catalog and list_price != catalog:
        return {
            "flagged": True,
            "reason": "billed_at_catalog_price_ignoring_pricelist",
            "delta_ex_tax": str(list_price - billed),
            "recommended_action": action,
        }

    return {
        "flagged": True,
        "reason": "billed_price_mismatch_unknown_source",
        "delta_ex_tax": str(list_price - billed),
        "recommended_action": action,
    }
diagnose.js
const UNPAID_STATUS_IDS = new Set([0, 7, 11]);

export function diagnoseOrderLinePricing(
  customerGroupId,
  assignedPriceListId,
  priceListRecordPriceExTax,
  catalogPriceExTax,
  billedPriceExTax,
  orderStatusId,
  hasCapturedTransaction
) {
  if (assignedPriceListId == null || priceListRecordPriceExTax == null) {
    return { flagged: false, reason: "no_price_list_assigned", deltaExTax: "0", recommendedAction: "none" };
  }

  const listPrice = Number(priceListRecordPriceExTax);
  const billed = Number(billedPriceExTax);
  const catalog = Number(catalogPriceExTax);

  if (billed === listPrice) {
    return { flagged: false, reason: "correctly_priced", deltaExTax: "0", recommendedAction: "none" };
  }

  const unpaid = UNPAID_STATUS_IDS.has(orderStatusId) && !hasCapturedTransaction;
  const action = unpaid ? "cancel_unpaid" : "report_refund_delta";

  if (billed === catalog && listPrice !== catalog) {
    return {
      flagged: true,
      reason: "billed_at_catalog_price_ignoring_pricelist",
      deltaExTax: (listPrice - billed).toFixed(2),
      recommendedAction: action,
    };
  }

  return {
    flagged: true,
    reason: "billed_price_mismatch_unknown_source",
    deltaExTax: (listPrice - billed).toFixed(2),
    recommendedAction: action,
  };
}
5

Never rewrite a placed order, only report

BigCommerce's V2 orders API has no "reprice line item" PUT. Once an order is created, its products and totals are largely immutable financial records, especially after a payment has been captured. When a line is flagged and unpaid with no captured transaction (checked via GET /v2/orders/{id}/transactions), the safe action is to cancel it with PUT /v2/orders/{id} {"status_id": 5} and let the integration recreate the order correctly. When it is already paid or shipped, the script only emits a report line recommending a manual refund via POST /v2/orders/{id}/refunds or a store credit for the delta. It never silently mutates a historical order.

apply.py
CANCELLED = 5

def cancel_unpaid_order(order_id):
    return bc_put_v2(f"/orders/{order_id}", {"status_id": CANCELLED})

def order_has_captured_transaction(order_id):
    transactions = bc_get_v2(f"/orders/{order_id}/transactions")
    for txn in transactions or []:
        kind = (txn.get("type") or txn.get("event") or "").lower()
        status = (txn.get("status") or "").lower()
        if kind in {"capture", "sale"} and status == "success":
            return True
    return False
apply.js
const CANCELLED = 5;

async function cancelUnpaidOrder(orderId) {
  return bcPutV2(`/orders/${orderId}`, { status_id: CANCELLED });
}

async function orderHasCapturedTransaction(orderId) {
  const transactions = await bcGetV2(`/orders/${orderId}/transactions`);
  for (const txn of transactions || []) {
    const kind = (txn.type || txn.event || "").toLowerCase();
    const status = (txn.status || "").toLowerCase();
    if ((kind === "capture" || kind === "sale") && status === "success") return true;
  }
  return false;
}
6

Wire it together with a dry run guard

The scan restricts itself to orders with status_id in the API-creation window (0 Incomplete, 7 Awaiting Payment, 9 Awaiting Shipment, 11 Awaiting Fulfillment) and date_created after the integration went live, via GET /v2/orders?min_date_created=...&customer_id={id}. DRY_RUN defaults to true, which is what this scan should almost always run as, since it is a report, not an auto-fix. The one write path, cancelling an unpaid order, only runs when DRY_RUN=false and the flagged order has no captured transaction.

Run it safe

This script is a flag-and-report tool first. Never let it rewrite price_ex_tax or price_inc_tax on a placed order. The only automated write it ever makes is cancelling an order that is both unpaid and has no captured transaction, and only when DRY_RUN=false. Everything else becomes a report line for a human to act on with a refund or store credit.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never mutates a paid or shipped order. It only ever cancels an order that is unpaid with no captured transaction, and reports everything else with the delta for a human to resolve.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

diagnose_order_pricing.py
"""Flag BigCommerce orders created via API that bypassed group and price list pricing.

POST /v2/orders is a back-office order-entry endpoint, not the storefront checkout
pricing engine. It only runs a cart through the pricing service if the caller omits
price fields entirely. When an integration supplies price_inc_tax/price_ex_tax on
each line, exactly what "pre-resolving" price client-side produces, BigCommerce
takes that number as authoritative and never resolves it against the customer's
assigned Price List or customer-group discount rules. Because the order has no
cart_id tying it back to a priced cart, there is no signal the submitted price is
stale or wrong. This job scans recent orders, resolves each customer's assigned
price list, and flags any line billed at plain catalog price (or any other price)
when the price list disagrees. It never rewrites a placed order's price fields; it
only cancels an unpaid order with no captured transaction, or reports a delta for a
human to refund or credit. Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/api-order-bypasses-group-pricing/
"""
import os
import logging
from decimal import Decimal, InvalidOperation

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diagnose_order_pricing")

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
V2_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
V3_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

UNPAID_STATUS_IDS = {0, 7, 11}
API_CREATION_WINDOW_STATUS_IDS = {0, 7, 9, 11}
CANCELLED = 5

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def bc_get_v2(path, params=None):
    r = requests.get(f"{V2_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []


def bc_put_v2(path, body):
    r = requests.put(f"{V2_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def bc_get_v3(path, params=None):
    r = requests.get(f"{V3_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {"data": []}


def diagnose_order_line_pricing(
    customer_group_id,
    assigned_price_list_id,
    price_list_record_price_ex_tax,
    catalog_price_ex_tax,
    billed_price_ex_tax,
    order_status_id,
    has_captured_transaction,
):
    """Pure decision logic, no I/O. All prices passed as decimal strings, compared via Decimal.

    Returns {"flagged": bool, "reason": str, "delta_ex_tax": str, "recommended_action": str}.
    If no assigned_price_list_id or no price_list_record_price_ex_tax: not flagged, the
    customer has no price-list override, so plain catalog price is correct.
    If billed_price_ex_tax == price_list_record_price_ex_tax: not flagged, correctly priced.
    If billed_price_ex_tax == catalog_price_ex_tax and price list disagrees with catalog:
    flagged, reason='billed_at_catalog_price_ignoring_pricelist'.
    Otherwise: flagged, reason='billed_price_mismatch_unknown_source'.
    recommended_action is 'cancel_unpaid' when order_status_id is in {0, 7, 11} and there
    is no captured transaction, else 'report_refund_delta'.
    """
    if assigned_price_list_id is None or price_list_record_price_ex_tax is None:
        return {
            "flagged": False,
            "reason": "no_price_list_assigned",
            "delta_ex_tax": "0",
            "recommended_action": "none",
        }

    try:
        list_price = Decimal(price_list_record_price_ex_tax)
        billed = Decimal(billed_price_ex_tax)
        catalog = Decimal(catalog_price_ex_tax)
    except (InvalidOperation, TypeError):
        return {
            "flagged": True,
            "reason": "billed_price_mismatch_unknown_source",
            "delta_ex_tax": "0",
            "recommended_action": "report_refund_delta",
        }

    if billed == list_price:
        return {
            "flagged": False,
            "reason": "correctly_priced",
            "delta_ex_tax": "0",
            "recommended_action": "none",
        }

    unpaid = order_status_id in UNPAID_STATUS_IDS and not has_captured_transaction
    action = "cancel_unpaid" if unpaid else "report_refund_delta"

    if billed == catalog and list_price != catalog:
        return {
            "flagged": True,
            "reason": "billed_at_catalog_price_ignoring_pricelist",
            "delta_ex_tax": str(list_price - billed),
            "recommended_action": action,
        }

    return {
        "flagged": True,
        "reason": "billed_price_mismatch_unknown_source",
        "delta_ex_tax": str(list_price - billed),
        "recommended_action": action,
    }


def candidate_orders():
    """Page through orders in the API-creation status window within the lookback window."""
    page = 1
    while True:
        orders = bc_get_v2(
            "/orders",
            {
                "min_date_created": f"-{LOOKBACK_DAYS} days",
                "page": page,
                "limit": 50,
            },
        )
        if not orders:
            return
        for order in orders:
            if order.get("status_id") in API_CREATION_WINDOW_STATUS_IDS:
                yield order
        page += 1


def order_products(order_id):
    return bc_get_v2(f"/orders/{order_id}/products")


def order_has_captured_transaction(order_id):
    transactions = bc_get_v2(f"/orders/{order_id}/transactions")
    for txn in transactions or []:
        kind = (txn.get("type") or txn.get("event") or "").lower()
        status = (txn.get("status") or "").lower()
        if kind in {"capture", "sale"} and status == "success":
            return True
    return False


def customer_group_id(customer_id):
    if not customer_id:
        return None
    data = bc_get_v3("/customers", {"id:in": customer_id})
    rows = data.get("data") or []
    return rows[0]["customer_group_id"] if rows else None


def assigned_price_list_id(customer_group_id_value):
    if not customer_group_id_value:
        return None
    data = bc_get_v3(
        "/pricelists/assignments",
        {"customer_group_id": customer_group_id_value, "channel_id": CHANNEL_ID},
    )
    rows = data.get("data") or []
    return rows[0]["price_list_id"] if rows else None


def price_list_record_price(price_list_id, variant_id):
    if not price_list_id:
        return None
    data = bc_get_v3(f"/pricelists/{price_list_id}/records", {"variant_id:in": variant_id})
    rows = data.get("data") or []
    return rows[0].get("price_ex_tax") if rows else None


def catalog_variant_price(product_id, variant_id):
    data = bc_get_v3(f"/catalog/products/{product_id}/variants/{variant_id}")
    row = data.get("data") or {}
    price = row.get("price")
    return str(price) if price is not None else None


def run():
    flagged_count = 0
    cancelled_count = 0

    for order in candidate_orders():
        order_id = order["id"]
        customer_id = order.get("customer_id")
        status_id = order.get("status_id")

        group_id = customer_group_id(customer_id)
        price_list_id = assigned_price_list_id(group_id)

        if price_list_id is None:
            continue

        has_captured = order_has_captured_transaction(order_id)

        for line in order_products(order_id) or []:
            product_id = line.get("product_id")
            variant_id = line.get("variant_id")
            billed = line.get("price_ex_tax")

            list_price = price_list_record_price(price_list_id, variant_id)
            catalog_price = catalog_variant_price(product_id, variant_id)

            result = diagnose_order_line_pricing(
                group_id, price_list_id, list_price, catalog_price, billed, status_id, has_captured
            )

            if not result["flagged"]:
                continue

            flagged_count += 1
            log.warning(
                "order_id=%s product_id=%s variant_id=%s billed=%s list_price=%s "
                "catalog_price=%s reason=%s delta=%s action=%s",
                order_id, product_id, variant_id, billed, list_price,
                catalog_price, result["reason"], result["delta_ex_tax"], result["recommended_action"],
            )

            if result["recommended_action"] == "cancel_unpaid":
                if not DRY_RUN:
                    bc_put_v2(f"/orders/{order_id}", {"status_id": CANCELLED})
                cancelled_count += 1

    log.info(
        "Done. %d line(s) flagged, %d order(s) %s for cancellation.",
        flagged_count, cancelled_count, "to cancel" if DRY_RUN else "cancelled",
    )


if __name__ == "__main__":
    run()
diagnose-order-pricing.js
/**
 * Flag BigCommerce orders created via API that bypassed group and price list pricing.
 *
 * POST /v2/orders is a back-office order-entry endpoint, not the storefront checkout
 * pricing engine. It only runs a cart through the pricing service if the caller omits
 * price fields entirely. When an integration supplies price_inc_tax/price_ex_tax on
 * each line, exactly what "pre-resolving" price client-side produces, BigCommerce
 * takes that number as authoritative and never resolves it against the customer's
 * assigned Price List or customer-group discount rules. Because the order has no
 * cart_id tying it back to a priced cart, there is no signal the submitted price is
 * stale or wrong. This job scans recent orders, resolves each customer's assigned
 * price list, and flags any line billed at plain catalog price (or any other price)
 * when the price list disagrees. It never rewrites a placed order's price fields; it
 * only cancels an unpaid order with no captured transaction, or reports a delta for a
 * human to refund or credit. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/api-order-bypasses-group-pricing/
 */
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 V2_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const V3_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const CHANNEL_ID = Number(process.env.CHANNEL_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const UNPAID_STATUS_IDS = new Set([0, 7, 11]);
const API_CREATION_WINDOW_STATUS_IDS = new Set([0, 7, 9, 11]);
const CANCELLED = 5;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/**
 * Pure decision logic, no I/O. All prices passed as decimal strings, compared as numbers.
 *
 * Returns { flagged, reason, deltaExTax, recommendedAction }.
 * If assignedPriceListId or priceListRecordPriceExTax is null: not flagged, the customer
 * has no price-list override, plain catalog price is correct.
 * If billedPriceExTax === priceListRecordPriceExTax: not flagged, correctly priced.
 * If billedPriceExTax === catalogPriceExTax and the price list disagrees with catalog:
 * flagged, reason 'billed_at_catalog_price_ignoring_pricelist'.
 * Otherwise: flagged, reason 'billed_price_mismatch_unknown_source'.
 * recommendedAction is 'cancel_unpaid' when orderStatusId is in {0, 7, 11} and there is
 * no captured transaction, else 'report_refund_delta'.
 */
export function diagnoseOrderLinePricing(
  customerGroupId,
  assignedPriceListId,
  priceListRecordPriceExTax,
  catalogPriceExTax,
  billedPriceExTax,
  orderStatusId,
  hasCapturedTransaction
) {
  if (assignedPriceListId == null || priceListRecordPriceExTax == null) {
    return { flagged: false, reason: "no_price_list_assigned", deltaExTax: "0", recommendedAction: "none" };
  }

  const listPrice = Number.parseFloat(priceListRecordPriceExTax);
  const billed = Number.parseFloat(billedPriceExTax);
  const catalog = Number.parseFloat(catalogPriceExTax);

  if (!Number.isFinite(listPrice) || !Number.isFinite(billed) || !Number.isFinite(catalog)) {
    return {
      flagged: true,
      reason: "billed_price_mismatch_unknown_source",
      deltaExTax: "0",
      recommendedAction: "report_refund_delta",
    };
  }

  if (billed === listPrice) {
    return { flagged: false, reason: "correctly_priced", deltaExTax: "0", recommendedAction: "none" };
  }

  const unpaid = UNPAID_STATUS_IDS.has(orderStatusId) && !hasCapturedTransaction;
  const action = unpaid ? "cancel_unpaid" : "report_refund_delta";

  if (billed === catalog && listPrice !== catalog) {
    return {
      flagged: true,
      reason: "billed_at_catalog_price_ignoring_pricelist",
      deltaExTax: (listPrice - billed).toFixed(2),
      recommendedAction: action,
    };
  }

  return {
    flagged: true,
    reason: "billed_price_mismatch_unknown_source",
    deltaExTax: (listPrice - billed).toFixed(2),
    recommendedAction: action,
  };
}

async function bcGetV2(path, params = {}) {
  const url = new URL(`${V2_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 bcPutV2(path, body) {
  const res = await fetch(`${V2_BASE}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcGetV3(path, params = {}) {
  const url = new URL(`${V3_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) : { data: [] };
}

async function* candidateOrders() {
  let page = 1;
  while (true) {
    const orders = await bcGetV2("/orders", {
      min_date_created: `-${LOOKBACK_DAYS} days`,
      page,
      limit: 50,
    });
    if (!orders.length) return;
    for (const order of orders) {
      if (API_CREATION_WINDOW_STATUS_IDS.has(order.status_id)) yield order;
    }
    page += 1;
  }
}

async function orderProducts(orderId) {
  return bcGetV2(`/orders/${orderId}/products`);
}

async function orderHasCapturedTransaction(orderId) {
  const transactions = await bcGetV2(`/orders/${orderId}/transactions`);
  for (const txn of transactions || []) {
    const kind = (txn.type || txn.event || "").toLowerCase();
    const status = (txn.status || "").toLowerCase();
    if ((kind === "capture" || kind === "sale") && status === "success") return true;
  }
  return false;
}

async function customerGroupId(customerId) {
  if (!customerId) return null;
  const data = await bcGetV3("/customers", { "id:in": customerId });
  const rows = data.data || [];
  return rows.length ? rows[0].customer_group_id : null;
}

async function assignedPriceListId(customerGroupIdValue) {
  if (!customerGroupIdValue) return null;
  const data = await bcGetV3("/pricelists/assignments", {
    customer_group_id: customerGroupIdValue,
    channel_id: CHANNEL_ID,
  });
  const rows = data.data || [];
  return rows.length ? rows[0].price_list_id : null;
}

async function priceListRecordPrice(priceListId, variantId) {
  if (!priceListId) return null;
  const data = await bcGetV3(`/pricelists/${priceListId}/records`, { "variant_id:in": variantId });
  const rows = data.data || [];
  return rows.length ? rows[0].price_ex_tax : null;
}

async function catalogVariantPrice(productId, variantId) {
  const data = await bcGetV3(`/catalog/products/${productId}/variants/${variantId}`);
  const row = data.data || {};
  return row.price != null ? String(row.price) : null;
}

export async function run() {
  let flaggedCount = 0;
  let cancelledCount = 0;

  for await (const order of candidateOrders()) {
    const orderId = order.id;
    const customerId = order.customer_id;
    const statusId = order.status_id;

    const groupId = await customerGroupId(customerId);
    const priceListId = await assignedPriceListId(groupId);

    if (priceListId == null) continue;

    const hasCaptured = await orderHasCapturedTransaction(orderId);
    const lines = await orderProducts(orderId);

    for (const line of lines || []) {
      const productId = line.product_id;
      const variantId = line.variant_id;
      const billed = line.price_ex_tax;

      const listPrice = await priceListRecordPrice(priceListId, variantId);
      const catalogPrice = await catalogVariantPrice(productId, variantId);

      const result = diagnoseOrderLinePricing(
        groupId, priceListId, listPrice, catalogPrice, billed, statusId, hasCaptured
      );

      if (!result.flagged) continue;

      flaggedCount += 1;
      console.warn(
        `order_id=${orderId} product_id=${productId} variant_id=${variantId} billed=${billed} ` +
        `list_price=${listPrice} catalog_price=${catalogPrice} reason=${result.reason} ` +
        `delta=${result.deltaExTax} action=${result.recommendedAction}`
      );

      if (result.recommendedAction === "cancel_unpaid") {
        if (!DRY_RUN) await bcPutV2(`/orders/${orderId}`, { status_id: CANCELLED });
        cancelledCount += 1;
      }
    }
  }

  console.log(
    `Done. ${flaggedCount} line(s) flagged, ${cancelledCount} order(s) ${DRY_RUN ? "to cancel" : "cancelled"} for cancellation.`
  );
}

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 a flagged order gets cancelled or only reported. Because diagnose_order_line_pricing takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in decimal strings and checks the answer.

test_bypass_pricing_diagnosis.py
from diagnose_order_pricing import diagnose_order_line_pricing


def test_not_flagged_when_no_price_list_assigned():
    result = diagnose_order_line_pricing(None, None, None, "50.00", "50.00", 11, False)
    assert result["flagged"] is False
    assert result["reason"] == "no_price_list_assigned"


def test_not_flagged_when_billed_matches_price_list():
    result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "40.00", 11, False)
    assert result["flagged"] is False
    assert result["reason"] == "correctly_priced"


def test_flagged_when_billed_at_catalog_price_ignoring_pricelist():
    result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "50.00", 11, False)
    assert result["flagged"] is True
    assert result["reason"] == "billed_at_catalog_price_ignoring_pricelist"
    assert result["delta_ex_tax"] == "-10.00"
    assert result["recommended_action"] == "cancel_unpaid"


def test_recommends_report_refund_delta_when_transaction_captured():
    result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "50.00", 11, True)
    assert result["flagged"] is True
    assert result["recommended_action"] == "report_refund_delta"


def test_recommends_report_refund_delta_when_order_already_shipped():
    result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "50.00", 2, False)
    assert result["flagged"] is True
    assert result["recommended_action"] == "report_refund_delta"


def test_flagged_billed_price_mismatch_unknown_source():
    result = diagnose_order_line_pricing(5, 9, "40.00", "50.00", "45.00", 7, False)
    assert result["flagged"] is True
    assert result["reason"] == "billed_price_mismatch_unknown_source"
    assert result["recommended_action"] == "cancel_unpaid"
diagnose-order-pricing.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseOrderLinePricing } from "./diagnose-order-pricing.js";

test("not flagged when no price list assigned", () => {
  const result = diagnoseOrderLinePricing(null, null, null, "50.00", "50.00", 11, false);
  assert.equal(result.flagged, false);
  assert.equal(result.reason, "no_price_list_assigned");
});

test("not flagged when billed matches price list", () => {
  const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "40.00", 11, false);
  assert.equal(result.flagged, false);
  assert.equal(result.reason, "correctly_priced");
});

test("flagged when billed at catalog price ignoring pricelist", () => {
  const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "50.00", 11, false);
  assert.equal(result.flagged, true);
  assert.equal(result.reason, "billed_at_catalog_price_ignoring_pricelist");
  assert.equal(result.deltaExTax, "-10.00");
  assert.equal(result.recommendedAction, "cancel_unpaid");
});

test("recommends report_refund_delta when transaction captured", () => {
  const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "50.00", 11, true);
  assert.equal(result.flagged, true);
  assert.equal(result.recommendedAction, "report_refund_delta");
});

test("recommends report_refund_delta when order already shipped", () => {
  const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "50.00", 2, false);
  assert.equal(result.flagged, true);
  assert.equal(result.recommendedAction, "report_refund_delta");
});

test("flagged billed price mismatch unknown source", () => {
  const result = diagnoseOrderLinePricing(5, 9, "40.00", "50.00", "45.00", 7, false);
  assert.equal(result.flagged, true);
  assert.equal(result.reason, "billed_price_mismatch_unknown_source");
  assert.equal(result.recommendedAction, "cancel_unpaid");
});

Case studies

B2B integration

The wholesale customer billed retail on every phone order

A distributor's sales team took phone orders and created them through the API by pulling each SKU's price from the product page and submitting it directly as price_ex_tax. Every one of those customers had a discounted price list assigned to their customer group, and every one of those orders billed full retail. The storefront checkout for the same customers was correct every time, because it always resolved the price list.

Running the scan against the last two weeks of orders surfaced dozens of flagged lines, each with a delta and a recommended action. The unpaid ones got cancelled and recreated correctly. The already-shipped ones went into a report for the accounts team to issue store credit.

Defensive pre-resolution

The integration that supplied both price fields to avoid a total bug

A middleware team had read BigCommerce's warning that overriding one price field without the other breaks total calculation, so their fix was to always compute and submit both price_ex_tax and price_inc_tax from the catalog price. That silenced the total bug but introduced this one: submitting any price field at all skips pricing resolution, so the price list was never consulted.

The real fix, once the report made the pattern obvious, was to stop computing price client-side entirely and instead post through POST /v3/carts with customer_id set, letting BigCommerce resolve price list and group discount automatically before converting the cart to an order.

What good looks like

After this runs on a schedule, every order billed at plain catalog price when a price list should have applied gets a report line with the exact delta, whether it needs a cancel-and-recreate or a manual refund. No placed order's price fields are ever silently rewritten. The real fix, building orders from a priced cart or omitting price fields so BigCommerce resolves them server-side, closes the gap for good, and the scan keeps catching anything that slips through in the meantime.

FAQ

Why does an order created through the BigCommerce API bill the wrong price?

POST /v2/orders is a back-office order-entry endpoint, not the storefront checkout pricing engine. If the caller supplies price_inc_tax or price_ex_tax on a line item, BigCommerce takes that number as authoritative and never resolves it against the customer's assigned price list or customer-group discount rules. Because the order has no cart_id tying it back to a priced cart, there is no signal to BigCommerce that the submitted price might be stale or wrong.

Can I fix a wrongly priced order by editing its price_ex_tax after the fact?

Not safely. Order products and totals are largely immutable financial records once created, especially after a payment has been captured. Rewriting price fields on a placed order risks desyncing totals, tax, and any already-captured transaction. Cancel and recreate the order if it is unpaid, or report the delta for a manual refund or store credit if it has already been paid or shipped.

What is the real fix so this stops happening on every order?

Stop pre-resolving price client-side in the integration. Build the order through a priced cart with POST /v3/carts, set customer_id on the cart so BigCommerce resolves the price list and customer group automatically, then convert that cart to an order. Alternatively, omit price_ex_tax and price_inc_tax entirely on POST /v2/orders/{id}/products so BigCommerce's pricing service resolves them server-side.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Orders Overview and the V2 order-entry model. developer.bigcommerce.com orders overview
  2. BigCommerce Developer Center: Order Products, price_ex_tax and price_inc_tax fields. developer.bigcommerce.com order products
  3. BigCommerce Orders API: Complete Integration Guide, V2 versus V3 order creation. ecommerce.folio3.com bigcommerce order apis v2 and v3

On the solution:

  1. BigCommerce Developer Center: Price Lists, assignments, and records. developer.bigcommerce.com price lists
  2. BigCommerce Developer Center: Price List API Overview. developer.bigcommerce.com price list api overview
  3. BigCommerce Developer Center: Price Order of Operations. developer.bigcommerce.com price order of operations

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, pricing, 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 catch a wrongly priced order?

If this saved a wholesale customer from being overbilled, or saved you the manual audit, 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