Repair Orders

Order shipping address update does not recompute tax or shipping cost

You call the API to fix a typo'd shipping address on an existing order. The address record updates cleanly. But total_tax and shipping_cost never move, because BigCommerce never re-runs the shipping-rate lookup or the tax engine on an order object, only inside cart and checkout consignment flows. The order now points at one destination and bills for another. Here is why that gap exists and a small script that finds the orders it happened to and repairs them safely.

Python and Node.js BigCommerce V2 Orders + V3 Checkouts APIs Safe by default (dry run)
A headset near a laptop
Photo by Petr Machacek on Unsplash
The short answer

PUT /v2/orders/{id}/shippingaddresses/{address_id} only writes the plain address fields, street_1, city, state, zip, country_iso2. It never re-runs the shipping-rate lookup or the tax engine, because both only happen inside cart and checkout consignment flows on /v3/checkouts, not on the order object itself. So total_tax, shipping_cost_ex_tax, shipping_cost_inc_tax, and base_shipping_cost stay exactly as they were at order creation, even after the address underneath them has changed. Run a small Python or Node.js script that diffs each order's live shipping address against a saved address hash, flags any order whose totals did not move after its address did, and, only for orders still in an editable status, gets a fresh shipping quote from a temporary checkout consignment and a fresh tax quote from the tax provider, then writes both totals fields together. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce's V2 Orders API treats the order shipping address as a plain address record, not as a pricing input. When you call PUT /v2/orders/{id}/shippingaddresses/{address_id} to fix a street name, a city, or a country, that endpoint writes exactly the fields you sent and stops there. It does not know anything about shipping rates or tax rules, because those calculations do not live on the order at all. They live in the cart and checkout consignment flow, on /v3/checkouts/{checkoutId}/consignments, which only runs while a customer is actively checking out.

Once an order exists, its money fields, base_shipping_cost, shipping_cost_ex_tax, shipping_cost_inc_tax, and total_tax, are frozen snapshots taken at order creation, or refreshed only if someone clicks Fetch shipping quotes in the control panel. Editing the shipping address through the API afterward changes the destination on paper without touching a single one of those numbers. The order silently drifts: it ships to a new place while billing for the old one.

PUT shipping address on order Address record street/city/zip saved No consignment, no estimate total_tax frozen at creation shipping_cost frozen at creation
The address record updates. The shipping rate lookup and the tax engine never run, because they only live inside checkout consignment flows, so both money fields stay stuck at their order-creation values.

Why it happens

BigCommerce's pricing engines are wired to the cart and checkout, not to the order. A few concrete reasons this desync shows up in practice:

The order still looks fine in the admin. The new address shows up in Shipping Details. The total looks like a normal number. Nothing on the order screen flags that the total was priced for a different state, country, or zip code than the one printed right above it.

The key insight

An order's shipping address and its money fields are not linked by the API the way they look linked in the admin UI. The address is just a record. The totals are a snapshot. So the safe pattern is not "recompute totals whenever an address looks recent." It is "diff the live address against the last address you saw, and only treat the order as stale if the address changed while the totals did not move afterward." Anything genuinely locked, Shipped, Completed, Refunded, Disputed, stays untouched no matter what the address diff says, because a payment or a tax filing may already be settled against the old numbers.

The fix, as a flow

We do not touch checkout or the live cart pricing engine. We add a job that lists candidate orders, diffs each one's current shipping address against a saved hash, and for anything genuinely stale and still in an editable status, builds a fresh quote the same way checkout would and writes it back consistently.

Scheduled job runs on a timer Hash live address vs saved hash Check totals moved and order status Changed and editable status? yes no, flag or skip locked Consignment quote + tax estimate
Only orders whose address changed while totals stayed byte-identical, and whose status is still editable, get a fresh consignment quote and tax estimate written back together.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Orders (modify) and Checkouts (modify) scope so it can read shipping addresses, create temporary consignments, and update order totals. 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 DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="14"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V2 Orders and V3 Checkouts REST APIs

Order reads and writes go to https://api.bigcommerce.com/stores/{store_hash}/v2/. Consignment quotes go to the V3 base, https://api.bigcommerce.com/stores/{store_hash}/v3/. Both use the same X-Auth-Token header. A small helper handles GET, PUT, and POST 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(base, path, params=None):
    r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []

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

def bc_post(base, path, body):
    r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const 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 bcGet(base, path, params = {}) {
  const url = new URL(`${base}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcPut(base, 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 bcPost(base, path, body) {
  const res = await fetch(`${base}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List candidate orders and read the live shipping address

Call GET /v2/orders?min_date_modified=... for the lookback window, then for each order call GET /v2/orders/{id}/shippingaddresses to read the current street_1, city, state, zip, and country_iso2. Compare that against a persisted "last-seen address hash," since the shipping address sub-resource exposes no modification timestamp of its own.

step3.py
def candidate_orders(lookback_days):
    page = 1
    while True:
        orders = bc_get(V2_BASE, "/orders", {
            "min_date_modified": f"-{lookback_days} days",
            "status_id": "0,1,7,9,11",
            "page": page,
            "limit": 50,
        })
        if not orders:
            return
        for order in orders:
            yield order
        page += 1

def live_shipping_address(order_id):
    addresses = bc_get(V2_BASE, f"/orders/{order_id}/shippingaddresses")
    return addresses[0] if addresses else None
step3.js
async function* candidateOrders(lookbackDays) {
  let page = 1;
  while (true) {
    const orders = await bcGet(V2_BASE, "/orders", {
      min_date_modified: `-${lookbackDays} days`,
      status_id: "0,1,7,9,11",
      page,
      limit: 50,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function liveShippingAddress(orderId) {
  const addresses = await bcGet(V2_BASE, `/orders/${orderId}/shippingaddresses`);
  return addresses[0] || null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order, the live shipping address, and the previously recorded address hash, and returns one of three actions. Locked statuses always win first. Then an address change with totals that never moved since the cached snapshot is what counts as stale.

decide.py
import hashlib

LOCKED_STATUSES = {2, 3, 4, 5, 6, 10, 13, 14}

def hash_address(address: dict) -> str:
    parts = [
        (address or {}).get("street_1", ""),
        (address or {}).get("city", ""),
        (address or {}).get("state", ""),
        (address or {}).get("zip", ""),
        (address or {}).get("country_iso2", ""),
    ]
    raw = "|".join(p.strip().lower() for p in parts)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

def decide_recompute(order, live_shipping_address, cached_address_hash):
    new_hash = hash_address(live_shipping_address)
    address_changed = new_hash != cached_address_hash
    status_id = order.get("status_id")

    if status_id in LOCKED_STATUSES:
        return {
            "address_changed": address_changed,
            "stale_totals": False,
            "action": "skip_locked_status",
            "reason": f"status_id {status_id} is locked; totals are never rewritten.",
        }

    totals_unchanged = order.get("_totals_unchanged_since_snapshot", True)

    if address_changed and totals_unchanged:
        return {
            "address_changed": True,
            "stale_totals": True,
            "action": "recompute",
            "reason": "Address changed but total_tax/shipping_cost did not move.",
        }

    return {
        "address_changed": address_changed,
        "stale_totals": False,
        "action": "flag_only",
        "reason": "No stale totals detected.",
    }
decide.js
import { createHash } from "node:crypto";

const LOCKED_STATUSES = new Set([2, 3, 4, 5, 6, 10, 13, 14]);

export function hashAddress(address) {
  const a = address || {};
  const parts = [a.street_1, a.city, a.state, a.zip, a.country_iso2].map((p) =>
    (p || "").trim().toLowerCase()
  );
  return createHash("sha256").update(parts.join("|")).digest("hex");
}

export function decideRecompute(order, liveShippingAddress, cachedAddressHash) {
  const newHash = hashAddress(liveShippingAddress);
  const addressChanged = newHash !== cachedAddressHash;
  const statusId = order.status_id;

  if (LOCKED_STATUSES.has(statusId)) {
    return {
      address_changed: addressChanged,
      stale_totals: false,
      action: "skip_locked_status",
      reason: `status_id ${statusId} is locked; totals are never rewritten.`,
    };
  }

  const totalsUnchanged = order._totalsUnchangedSinceSnapshot !== false;

  if (addressChanged && totalsUnchanged) {
    return {
      address_changed: true,
      stale_totals: true,
      action: "recompute",
      reason: "Address changed but total_tax/shipping_cost did not move.",
    };
  }

  return {
    address_changed: addressChanged,
    stale_totals: false,
    action: "flag_only",
    reason: "No stale totals detected.",
  };
}
5

Get an authoritative quote, the same way checkout would

When the decision is recompute, build a temporary checkout consignment with the new shipping address and the order's line items, POST /v3/checkouts/{checkoutId}/consignments?include=consignments.availableShippingOptions, to get a real shipping_option cost for the new destination. Then call the tax provider's Estimate Taxes endpoint with the same address to get a fresh tax quote. Neither of these touches the live order yet.

quote.py
def get_shipping_quote(checkout_id, new_address, line_items):
    body = {
        "line_items": line_items,
        "shipping_address": new_address,
    }
    result = bc_post(
        V3_BASE,
        f"/checkouts/{checkout_id}/consignments?include=consignments.availableShippingOptions",
        [body],
    )
    consignments = result.get("data", {}).get("consignments", [])
    options = consignments[0].get("available_shipping_options", []) if consignments else []
    return options[0] if options else None

def get_tax_estimate(new_address, line_items):
    body = {"address": new_address, "line_items": line_items}
    return bc_post(V3_BASE, "/tax-provider/estimate", body)
quote.js
async function getShippingQuote(checkoutId, newAddress, lineItems) {
  const body = { line_items: lineItems, shipping_address: newAddress };
  const result = await bcPost(
    V3_BASE,
    `/checkouts/${checkoutId}/consignments?include=consignments.availableShippingOptions`,
    [body]
  );
  const consignments = result?.data?.consignments || [];
  const options = consignments[0]?.available_shipping_options || [];
  return options[0] || null;
}

async function getTaxEstimate(newAddress, lineItems) {
  const body = { address: newAddress, line_items: lineItems };
  return bcPost(V3_BASE, "/tax-provider/estimate", body);
}
6

Write both totals fields together, and only when unlocked

When not in dry run, call PUT /v2/orders/{id} with shipping_cost_ex_tax and shipping_cost_inc_tax set together, since BigCommerce requires both at once, and update total_tax consistently with subtotal_tax and handling_cost. Never write to an order whose status_id is in the locked set. Log the before and after diff either way.

apply.py
def write_recomputed_totals(order_id, shipping_ex_tax, shipping_inc_tax, total_tax, subtotal_tax, handling_cost):
    body = {
        "shipping_cost_ex_tax": f"{shipping_ex_tax:.2f}",
        "shipping_cost_inc_tax": f"{shipping_inc_tax:.2f}",
        "total_tax": f"{total_tax:.2f}",
        "subtotal_tax": f"{subtotal_tax:.2f}",
        "handling_cost": f"{handling_cost:.2f}",
    }
    return bc_put(V2_BASE, f"/orders/{order_id}", body)
apply.js
async function writeRecomputedTotals(orderId, shippingExTax, shippingIncTax, totalTax, subtotalTax, handlingCost) {
  const body = {
    shipping_cost_ex_tax: shippingExTax.toFixed(2),
    shipping_cost_inc_tax: shippingIncTax.toFixed(2),
    total_tax: totalTax.toFixed(2),
    subtotal_tax: subtotalTax.toFixed(2),
    handling_cost: handlingCost.toFixed(2),
  };
  return bcPut(V2_BASE, `/orders/${orderId}`, body);
}
Run it safe

Always start with DRY_RUN=true, and never write totals to an order whose status_id is Shipped, Partially Shipped, Refunded, Cancelled, Declined, Completed, Disputed, or Partially Refunded. Rewriting a live order's money fields is financially sensitive, since a payment or a tax filing may already be settled against the old numbers. Default to flagging for human review, and only apply the write when explicitly un-dry-run'd.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs every decision, respects the dry run flag, and only ever writes totals for orders still in Incomplete, Pending, Awaiting Payment, Awaiting Shipment, or Awaiting Fulfillment status.

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

recompute_stale_totals.py
"""Detect and repair BigCommerce orders whose shipping address changed but
whose tax and shipping totals never recomputed.

BigCommerce's V2 Orders API treats the order shipping address as a plain
address record, not a pricing input. PUT /v2/orders/{id}/shippingaddresses/{id}
only writes street/city/zip/country fields and never re-runs the shipping-rate
lookup or the tax engine, because both only happen inside cart and checkout
consignment flows on /v3/checkouts, not on the order object itself. Order-level
fields like base_shipping_cost, shipping_cost_ex_tax/inc_tax, and total_tax are
static snapshots taken at order creation, so editing the address afterward
silently desyncs those money fields from the real destination.

This job lists candidate orders, diffs the live shipping address against a
saved address hash, and for orders that are still in an editable status
(Incomplete, Pending, Awaiting Payment, Awaiting Shipment, Awaiting
Fulfillment) with stale totals, builds a fresh checkout consignment quote and
a fresh tax estimate, then writes shipping_cost_ex_tax, shipping_cost_inc_tax,
and total_tax back together. Orders in a locked status (Shipped, Partially
Shipped, Refunded, Cancelled, Declined, Completed, Disputed, Partially
Refunded) are always skipped. Safe to run again and again. Defaults to
DRY_RUN, which only logs what it would flag or write.

Guide: https://www.allanninal.dev/bigcommerce/shipping-address-update-stale-totals/
"""
import hashlib
import logging
import os

import requests

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

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"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

EDITABLE_STATUSES = {0, 1, 7, 9, 11}
LOCKED_STATUSES = {2, 3, 4, 5, 6, 10, 13, 14}

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


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


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


def bc_post(base, path, body):
    r = requests.post(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def hash_address(address: dict) -> str:
    """Stable hash of the fields that actually affect shipping and tax."""
    parts = [
        (address or {}).get("street_1", ""),
        (address or {}).get("city", ""),
        (address or {}).get("state", ""),
        (address or {}).get("zip", ""),
        (address or {}).get("country_iso2", ""),
    ]
    raw = "|".join(p.strip().lower() for p in parts)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def decide_recompute(order: dict, live_shipping_address: dict, cached_address_hash: str) -> dict:
    """Pure decision logic. No I/O.

    Given the last-known order dict (status_id and a marker for whether its
    totals have moved since the cached address snapshot), the current live
    shipping address, and the previously recorded address hash, decide
    whether the order's totals are stale and whether a repair is safe.

    This function never reads DRY_RUN. It decides what should happen; the
    caller decides whether a "recompute" action is written or only logged.

    order["_totals_unchanged_since_snapshot"] defaults to True: callers that
    already know the totals moved (for example because date_modified moved
    after the address changed) should pass False explicitly.
    """
    new_hash = hash_address(live_shipping_address)
    address_changed = new_hash != cached_address_hash
    status_id = order.get("status_id")

    if status_id in LOCKED_STATUSES:
        return {
            "address_changed": address_changed,
            "stale_totals": False,
            "action": "skip_locked_status",
            "reason": f"status_id {status_id} is locked; totals are never rewritten.",
        }

    totals_unchanged = order.get("_totals_unchanged_since_snapshot", True)

    if address_changed and totals_unchanged:
        return {
            "address_changed": True,
            "stale_totals": True,
            "action": "recompute",
            "reason": "Address changed but total_tax/shipping_cost did not move.",
        }

    return {
        "address_changed": address_changed,
        "stale_totals": False,
        "action": "flag_only",
        "reason": "No stale totals detected.",
    }


def candidate_orders():
    """Page through orders in an editable status within the lookback window."""
    page = 1
    while True:
        orders = bc_get(
            V2_BASE,
            "/orders",
            {
                "min_date_modified": f"-{LOOKBACK_DAYS} days",
                "status_id": ",".join(str(s) for s in sorted(EDITABLE_STATUSES | LOCKED_STATUSES)),
                "page": page,
                "limit": 50,
            },
        )
        if not orders:
            return
        for order in orders:
            yield order
        page += 1


def live_shipping_address(order_id):
    addresses = bc_get(V2_BASE, f"/orders/{order_id}/shippingaddresses")
    return addresses[0] if addresses else None


def order_line_items(order_id):
    return bc_get(V2_BASE, f"/orders/{order_id}/products")


def get_shipping_quote(checkout_id, new_address, line_items):
    body = {"line_items": line_items, "shipping_address": new_address}
    result = bc_post(
        V3_BASE,
        f"/checkouts/{checkout_id}/consignments?include=consignments.availableShippingOptions",
        [body],
    )
    consignments = (result or {}).get("data", {}).get("consignments", [])
    options = consignments[0].get("available_shipping_options", []) if consignments else []
    return options[0] if options else None


def get_tax_estimate(new_address, line_items):
    body = {"address": new_address, "line_items": line_items}
    return bc_post(V3_BASE, "/tax-provider/estimate", body)


def write_recomputed_totals(order_id, shipping_ex_tax, shipping_inc_tax, total_tax, subtotal_tax, handling_cost):
    body = {
        "shipping_cost_ex_tax": f"{shipping_ex_tax:.2f}",
        "shipping_cost_inc_tax": f"{shipping_inc_tax:.2f}",
        "total_tax": f"{total_tax:.2f}",
        "subtotal_tax": f"{subtotal_tax:.2f}",
        "handling_cost": f"{handling_cost:.2f}",
    }
    return bc_put(V2_BASE, f"/orders/{order_id}", body)


def load_cached_address_hash(order_id):
    """Placeholder for your own persistence layer (database, key/value store).
    Replace with a real lookup keyed on order_id. Returning None means
    "never seen before," which is treated as a change on the first pass.
    """
    return None


def save_address_hash(order_id, address_hash):
    """Placeholder for your own persistence layer."""
    return None


def run():
    recomputed = 0
    flagged = 0
    skipped = 0

    for order in candidate_orders():
        order_id = order["id"]
        status_id = order.get("status_id")
        address = live_shipping_address(order_id)
        cached_hash = load_cached_address_hash(order_id)

        decision = decide_recompute(order, address, cached_hash)

        if decision["action"] == "skip_locked_status":
            skipped += 1
            continue

        if decision["action"] == "flag_only":
            if decision["stale_totals"]:
                log.warning(
                    "Order %s flagged for review. status_id=%s reason=%s",
                    order_id, status_id, decision["reason"],
                )
                flagged += 1
            save_address_hash(order_id, hash_address(address))
            continue

        line_items = order_line_items(order_id)
        checkout_id = order.get("checkout_id") or order.get("cart_id")
        shipping_option = get_shipping_quote(checkout_id, address, line_items) if checkout_id else None
        tax_estimate = get_tax_estimate(address, line_items)

        shipping_ex_tax = float((shipping_option or {}).get("cost", 0) or 0)
        tax_total = float((tax_estimate or {}).get("total_tax", 0) or 0)
        shipping_inc_tax = shipping_ex_tax + tax_total
        subtotal_tax = float((tax_estimate or {}).get("subtotal_tax", tax_total) or tax_total)
        handling_cost = float(order.get("handling_cost_ex_tax", 0) or 0)

        log.info(
            "order_id=%s status_id=%s new_shipping_ex_tax=%.2f new_shipping_inc_tax=%.2f "
            "new_total_tax=%.2f (%s)",
            order_id, status_id, shipping_ex_tax, shipping_inc_tax, tax_total,
            "dry run" if DRY_RUN else "writing",
        )

        if not DRY_RUN:
            write_recomputed_totals(
                order_id, shipping_ex_tax, shipping_inc_tax, tax_total, subtotal_tax, handling_cost
            )
        save_address_hash(order_id, hash_address(address))
        recomputed += 1

    log.info(
        "Done. %d order(s) %s, %d flagged for review, %d skipped (locked status).",
        recomputed, "to recompute" if DRY_RUN else "recomputed", flagged, skipped,
    )


if __name__ == "__main__":
    run()
recompute-stale-totals.js
/**
 * Detect and repair BigCommerce orders whose shipping address changed but
 * whose tax and shipping totals never recomputed.
 *
 * BigCommerce's V2 Orders API treats the order shipping address as a plain
 * address record, not a pricing input. PUT /v2/orders/{id}/shippingaddresses/{id}
 * only writes street/city/zip/country fields and never re-runs the shipping-rate
 * lookup or the tax engine, because both only happen inside cart and checkout
 * consignment flows on /v3/checkouts, not on the order object itself. Order-level
 * fields like base_shipping_cost, shipping_cost_ex_tax/inc_tax, and total_tax are
 * static snapshots taken at order creation, so editing the address afterward
 * silently desyncs those money fields from the real destination.
 *
 * This job lists candidate orders, diffs the live shipping address against a
 * saved address hash, and for orders that are still in an editable status
 * (Incomplete, Pending, Awaiting Payment, Awaiting Shipment, Awaiting
 * Fulfillment) with stale totals, builds a fresh checkout consignment quote and
 * a fresh tax estimate, then writes shipping_cost_ex_tax, shipping_cost_inc_tax,
 * and total_tax back together. Orders in a locked status are always skipped.
 * Safe to run again and again. Defaults to DRY_RUN.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/shipping-address-update-stale-totals/
 */
import { createHash } from "node:crypto";
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const EDITABLE_STATUSES = new Set([0, 1, 7, 9, 11]);
const LOCKED_STATUSES = new Set([2, 3, 4, 5, 6, 10, 13, 14]);

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

/** Stable hash of the fields that actually affect shipping and tax. */
export function hashAddress(address) {
  const a = address || {};
  const parts = [a.street_1, a.city, a.state, a.zip, a.country_iso2].map((p) =>
    (p || "").trim().toLowerCase()
  );
  return createHash("sha256").update(parts.join("|")).digest("hex");
}

/**
 * Pure decision logic. No I/O.
 *
 * Given the last-known order (status_id and a marker for whether its totals
 * have moved since the cached address snapshot), the current live shipping
 * address, and the previously recorded address hash, decide whether the
 * order's totals are stale and whether a repair is safe.
 *
 * order._totalsUnchangedSinceSnapshot defaults to true: callers that already
 * know the totals moved should pass false explicitly.
 *
 * This function never reads DRY_RUN. It decides what should happen; the
 * caller decides whether a "recompute" action is written or only logged.
 */
export function decideRecompute(order, liveShippingAddress, cachedAddressHash) {
  const newHash = hashAddress(liveShippingAddress);
  const addressChanged = newHash !== cachedAddressHash;
  const statusId = order.status_id;

  if (LOCKED_STATUSES.has(statusId)) {
    return {
      address_changed: addressChanged,
      stale_totals: false,
      action: "skip_locked_status",
      reason: `status_id ${statusId} is locked; totals are never rewritten.`,
    };
  }

  const totalsUnchanged = order._totalsUnchangedSinceSnapshot !== false;

  if (addressChanged && totalsUnchanged) {
    return {
      address_changed: true,
      stale_totals: true,
      action: "recompute",
      reason: "Address changed but total_tax/shipping_cost did not move.",
    };
  }

  return {
    address_changed: addressChanged,
    stale_totals: false,
    action: "flag_only",
    reason: "No stale totals detected.",
  };
}

async function bcGet(base, path, params = {}) {
  const url = new URL(`${base}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

async function bcPut(base, 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 bcPost(base, path, body) {
  const res = await fetch(`${base}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* candidateOrders() {
  let page = 1;
  const statuses = [...EDITABLE_STATUSES, ...LOCKED_STATUSES].sort((a, b) => a - b).join(",");
  while (true) {
    const orders = await bcGet(V2_BASE, "/orders", {
      min_date_modified: `-${LOOKBACK_DAYS} days`,
      status_id: statuses,
      page,
      limit: 50,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function liveShippingAddress(orderId) {
  const addresses = await bcGet(V2_BASE, `/orders/${orderId}/shippingaddresses`);
  return addresses[0] || null;
}

async function orderLineItems(orderId) {
  return bcGet(V2_BASE, `/orders/${orderId}/products`);
}

async function getShippingQuote(checkoutId, newAddress, lineItems) {
  const body = { line_items: lineItems, shipping_address: newAddress };
  const result = await bcPost(
    V3_BASE,
    `/checkouts/${checkoutId}/consignments?include=consignments.availableShippingOptions`,
    [body]
  );
  const consignments = result?.data?.consignments || [];
  const options = consignments[0]?.available_shipping_options || [];
  return options[0] || null;
}

async function getTaxEstimate(newAddress, lineItems) {
  const body = { address: newAddress, line_items: lineItems };
  return bcPost(V3_BASE, "/tax-provider/estimate", body);
}

async function writeRecomputedTotals(orderId, shippingExTax, shippingIncTax, totalTax, subtotalTax, handlingCost) {
  const body = {
    shipping_cost_ex_tax: shippingExTax.toFixed(2),
    shipping_cost_inc_tax: shippingIncTax.toFixed(2),
    total_tax: totalTax.toFixed(2),
    subtotal_tax: subtotalTax.toFixed(2),
    handling_cost: handlingCost.toFixed(2),
  };
  return bcPut(V2_BASE, `/orders/${orderId}`, body);
}

/** Placeholder for your own persistence layer (database, key/value store). */
async function loadCachedAddressHash(_orderId) {
  return null;
}

/** Placeholder for your own persistence layer. */
async function saveAddressHash(_orderId, _addressHash) {
  return null;
}

export async function run() {
  let recomputed = 0;
  let flagged = 0;
  let skipped = 0;

  for await (const order of candidateOrders()) {
    const orderId = order.id;
    const statusId = order.status_id;
    const address = await liveShippingAddress(orderId);
    const cachedHash = await loadCachedAddressHash(orderId);

    const decision = decideRecompute(order, address, cachedHash);

    if (decision.action === "skip_locked_status") {
      skipped += 1;
      continue;
    }

    if (decision.action === "flag_only") {
      if (decision.stale_totals) {
        console.warn(`Order ${orderId} flagged for review. status_id=${statusId} reason=${decision.reason}`);
        flagged += 1;
      }
      await saveAddressHash(orderId, hashAddress(address));
      continue;
    }

    const lineItems = await orderLineItems(orderId);
    const checkoutId = order.checkout_id || order.cart_id;
    const shippingOption = checkoutId ? await getShippingQuote(checkoutId, address, lineItems) : null;
    const taxEstimate = await getTaxEstimate(address, lineItems);

    const shippingExTax = Number(shippingOption?.cost || 0);
    const taxTotal = Number(taxEstimate?.total_tax || 0);
    const shippingIncTax = shippingExTax + taxTotal;
    const subtotalTax = Number(taxEstimate?.subtotal_tax ?? taxTotal);
    const handlingCost = Number(order.handling_cost_ex_tax || 0);

    console.log(
      `order_id=${orderId} status_id=${statusId} new_shipping_ex_tax=${shippingExTax.toFixed(2)} ` +
      `new_shipping_inc_tax=${shippingIncTax.toFixed(2)} new_total_tax=${taxTotal.toFixed(2)} ` +
      `(${DRY_RUN ? "dry run" : "writing"})`
    );

    if (!DRY_RUN) {
      await writeRecomputedTotals(orderId, shippingExTax, shippingIncTax, taxTotal, subtotalTax, handlingCost);
    }
    await saveAddressHash(orderId, hashAddress(address));
    recomputed += 1;
  }

  console.log(
    `Done. ${recomputed} order(s) ${DRY_RUN ? "to recompute" : "recomputed"}, ${flagged} flagged for review, ${skipped} skipped (locked status).`
  );
}

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 live order's money fields get rewritten. Because decide_recompute takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_shipping_address_recompute.py
from recompute_stale_totals import decide_recompute, hash_address


def address(street="123 Main St", city="Austin", state="TX", zip_="78701", country="US"):
    return {"street_1": street, "city": city, "state": state, "zip": zip_, "country_iso2": country}


def test_skip_locked_status_even_if_address_changed():
    order = {"status_id": 2}
    result = decide_recompute(order, address(city="Dallas"), hash_address(address()))
    assert result["action"] == "skip_locked_status"
    assert result["stale_totals"] is False


def test_recompute_when_address_changed_and_totals_unchanged():
    order = {"status_id": 9, "_totals_unchanged_since_snapshot": True}
    result = decide_recompute(order, address(city="Dallas"), hash_address(address()))
    assert result["address_changed"] is True
    assert result["stale_totals"] is True
    assert result["action"] == "recompute"


def test_flag_only_when_address_unchanged():
    same_address = address()
    order = {"status_id": 11, "_totals_unchanged_since_snapshot": True}
    result = decide_recompute(order, same_address, hash_address(same_address))
    assert result["address_changed"] is False
    assert result["stale_totals"] is False
    assert result["action"] == "flag_only"


def test_flag_only_when_address_changed_but_totals_already_moved():
    order = {"status_id": 7, "_totals_unchanged_since_snapshot": False}
    result = decide_recompute(order, address(city="Dallas"), hash_address(address()))
    assert result["address_changed"] is True
    assert result["stale_totals"] is False
    assert result["action"] == "flag_only"


def test_hash_address_is_case_and_whitespace_insensitive():
    a = address(city="Austin")
    b = address(city=" austin ")
    assert hash_address(a) == hash_address(b)


def test_hash_address_changes_when_zip_changes():
    assert hash_address(address(zip_="78701")) != hash_address(address(zip_="90001"))
recompute-stale-totals.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRecompute, hashAddress } from "./recompute-stale-totals.js";

const address = ({ street_1 = "123 Main St", city = "Austin", state = "TX", zip = "78701", country_iso2 = "US" } = {}) => (
  { street_1, city, state, zip, country_iso2 }
);

test("skip_locked_status even if address changed", () => {
  const order = { status_id: 2 };
  const result = decideRecompute(order, address({ city: "Dallas" }), hashAddress(address()));
  assert.equal(result.action, "skip_locked_status");
  assert.equal(result.stale_totals, false);
});

test("recompute when address changed and totals unchanged", () => {
  const order = { status_id: 9, _totalsUnchangedSinceSnapshot: true };
  const result = decideRecompute(order, address({ city: "Dallas" }), hashAddress(address()));
  assert.equal(result.address_changed, true);
  assert.equal(result.stale_totals, true);
  assert.equal(result.action, "recompute");
});

test("flag_only when address unchanged", () => {
  const sameAddress = address();
  const order = { status_id: 11, _totalsUnchangedSinceSnapshot: true };
  const result = decideRecompute(order, sameAddress, hashAddress(sameAddress));
  assert.equal(result.address_changed, false);
  assert.equal(result.stale_totals, false);
  assert.equal(result.action, "flag_only");
});

test("flag_only when address changed but totals already moved", () => {
  const order = { status_id: 7, _totalsUnchangedSinceSnapshot: false };
  const result = decideRecompute(order, address({ city: "Dallas" }), hashAddress(address()));
  assert.equal(result.address_changed, true);
  assert.equal(result.stale_totals, false);
  assert.equal(result.action, "flag_only");
});

test("hashAddress is case and whitespace insensitive", () => {
  const a = address({ city: "Austin" });
  const b = address({ city: " austin " });
  assert.equal(hashAddress(a), hashAddress(b));
});

test("hashAddress changes when zip changes", () => {
  assert.notEqual(hashAddress(address({ zip: "78701" })), hashAddress(address({ zip: "90001" })));
});

Case studies

Support fixed the typo, not the total

The store where a support macro only patched the street name

A customer texted in a typo in their delivery street name after checkout. A support rep pulled up the order and called the shipping address update endpoint directly to fix it, exactly what the endpoint is documented to do. The order shipped to the corrected address. Weeks later, finance flagged a handful of orders where the shipping cost on the invoice did not match the zone the package actually went to.

The recompute job now runs nightly against orders still Awaiting Shipment or Awaiting Fulfillment. It hashes each order's live address, catches the ones where a typo fix went out through the API, and gets a real consignment quote for the corrected destination before anything ships, instead of finance finding it after the fact.

Cross-border correction

The order that moved from one country to another after creation

A customer asked to have an order re-routed to a relative's address in a different country entirely, a request the merchant's internal tool handled by calling the shipping address endpoint with the new country_iso2. The tax rate for the new destination was completely different from the original one, but total_tax never moved, because nothing in that write path touches the tax engine.

Because the decision function checks status_id first, orders like this one that were still Awaiting Fulfillment got a fresh tax estimate and a corrected total_tax before the order was packed. Orders that had already shipped by the time the job ran were correctly left alone and flagged for manual reconciliation instead.

What good looks like

After this runs on a schedule, an address correction through the API never quietly outlives the totals it was calculated against. Orders still in an editable status get a fresh, checkout-equivalent shipping quote and tax estimate written back together. Orders that have already shipped, been paid out, refunded, or disputed are never touched automatically, no matter how their address looks, and instead surface as a clear flag for a human to reconcile by hand.

FAQ

Why does updating an order's shipping address not update the tax and shipping cost?

PUT /v2/orders/{id}/shippingaddresses/{address_id} only writes the address record itself, fields like street_1, city, state, zip, and country. It never re-runs the shipping-rate lookup or the tax engine, because both of those only run inside cart and checkout consignment flows. Order-level money fields like total_tax and shipping_cost_ex_tax are static snapshots taken at order creation, so editing the address afterward silently desyncs them from the new destination.

Is it safe to auto-write new totals onto every order with a changed address?

No. Only orders still in Incomplete, Pending, Awaiting Payment, Awaiting Shipment, or Awaiting Fulfillment status are safe to repair automatically, and even then the default behavior should be to flag the discrepancy for a human. Shipped, Completed, Refunded, Cancelled, Disputed, or Partially Refunded orders should never have their money fields rewritten automatically, since payments and tax filings may already be settled against the old totals.

Can I update just total_tax or just shipping_cost on an order?

No. BigCommerce requires that related totals fields are written together. If you override shipping_cost_ex_tax you must also set shipping_cost_inc_tax, and total_tax needs to stay internally consistent with subtotal_tax and handling_cost. Writing one field without the others leaves the order in a different but still inconsistent state.

Related field notes

Citations

On the problem:

  1. BigCommerce API Reference: Update Order Shipping Address. docs.bigcommerce.com update order shipping address
  2. BigCommerce Support Community: using the APIs to update an order's shipping address or shipping method. support.bigcommerce.com update shipping address or method
  3. BigCommerce Support: Editing an Order. support.bigcommerce.com editing an order

On the solution:

  1. BigCommerce API Reference: Update Checkout Consignment. docs.bigcommerce.com update checkout consignment
  2. BigCommerce API Reference: Estimate Taxes (Tax Provider API). docs.bigcommerce.com estimate taxes
  3. BigCommerce API Reference: Adjust Tax Quote (Tax Provider API). docs.bigcommerce.com adjust tax quote

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a stale total before it shipped?

If this saved you a finance reconciliation headache or caught a mismatched tax total you would have otherwise missed, 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