Reconciler Webhooks

Status webhook payload carries only an id, hiding dropped updates

A store/order/statusUpdated webhook fires. Your endpoint receives it, returns 200 OK, and BigCommerce considers the job done. But the payload never told you what the new status actually is, only which order changed. If the follow-up call you make to find out fails, times out, or your app crashes before it finishes, the notification is gone. There is no record that anything changed, no queue to replay from, and BigCommerce will never resend it because as far as it is concerned, delivery already succeeded. Here is why that gap exists and a small reconciler that finds the orders it left behind.

Python and Node.js BigCommerce V2 Orders API Safe by default (report only)
Person using laptop computer holding card
Photo by rupixen on Unsplash
The short answer

The store/order/statusUpdated scope's data object carries only {"type":"order","id":<order_id>}, never the resulting status_id or status text. Your app is required to make a synchronous follow-up GET /v2/orders/{id} to learn what actually changed. If that GET fails, times out, hits a rate limit, or the app crashes between the webhook arriving and the GET completing, the update is dropped with nothing left to retry from, because a webhook that BigCommerce delivered successfully (200 OK) is never resent even if your own internal follow-up call fails afterward. Run a small Python or Node.js reconciler that keeps a local last-known status_id per order, periodically lists orders modified since the last check with GET /v2/orders?min_date_modified=..., and flags any order whose status_id does not match what you have on record. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce webhook payloads are deliberately thin. When an order's status changes, the store/order/statusUpdated event fires with a data object that BigCommerce's own docs describe as containing "only the order id", something like {"scope":"store/order/statusUpdated","data":{"type":"order","id":1234},"hash":"...","producer":"stores/{store_hash}"}. There is no status_id, no status text, nothing about what actually changed, just a pointer telling you to go look.

So every consumer of this webhook is required to do the same thing: receive the event, then make a synchronous GET /v2/orders/{id} to find out what the new status_id actually is. That follow-up call is where the real risk sits. If it fails, times out, gets rate limited, or the process crashes in the window between the webhook landing and the GET completing, the information "this order's status changed" is gone. There is no local record of the event, no queue entry, nothing to retry, because the webhook itself carried no state to replay from in the first place.

BigCommerce's own retry behavior makes this worse in a way that is easy to miss. If your webhook endpoint itself returns a 5xx or times out, BigCommerce retries delivery for about 48 hours and then deactivates the hook, setting is_active:false. But that retry logic only covers the delivery of the webhook itself. If your endpoint answers 200 OK right away and only your own internal follow-up GET fails afterward, BigCommerce never sees a failure at all. From its side, that delivery succeeded. Nothing is queued, nothing is retried, and the dropped status change is invisible until someone notices the order looks stale.

statusUpdated {type, id} only App returns 200 OK Follow-up GET /v2/orders/{id} GET fails or crashes Status change never recorded no retry, BigCommerce saw delivery succeed
The webhook already told BigCommerce it succeeded the moment your endpoint answered 200 OK. The follow-up GET that actually reveals the new status is on its own, with nothing to retry it if it fails.

Why it happens

A few things push stores toward this exact gap:

This matches what BigCommerce's own developer docs say about the payload shape, and it is the same pattern merchants report in support threads where an order's webhook fired but the fetched status still came back stale or wrong. See the citations at the end for the exact references.

The key insight

You cannot ask BigCommerce which webhooks silently failed after your own 200 OK, that information does not exist on their side. The only way to find a dropped status change is to compare your own last-known state for each order against what BigCommerce's Orders API says right now. GET /v2/orders?min_date_modified=... becomes the reconciliation source of truth, not the webhook stream, and the webhook becomes just a low-latency hint that something might be worth checking sooner.

The fix, as a flow

We do not touch the webhook handler's fast path. We add a separate reconciliation job that periodically pulls every order modified since the last successful pass, compares each one's status_id against a locally stored last-known value, and reports any mismatch as a dropped update for a human or an automated re-sync to pick up.

Scheduled job runs on a timer List modified orders min_date_modified Compare status_id vs last-known local Mismatch or no record? yes no, in sync Flag dropped update, re-sync
The reconciler never guesses. It re-derives the truth from BigCommerce's own Orders API and only flags the orders where the local shadow copy disagrees with what BigCommerce says right now.

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) scope so it can list and read orders, and give it access to /v3/hooks so it can confirm the webhook is still active. 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 RECONCILE_LOOKBACK_HOURS="24"
export DRY_RUN="true"   # start safe, change to false to write local state
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export RECONCILE_LOOKBACK_HOURS="24"
export DRY_RUN="true"   // start safe, change to false to write local state
2

Talk to the V2 Orders API and the v3 Hooks API

Order listing and reads go to https://api.bigcommerce.com/stores/{store_hash}/v2/, hook status checks go to the same host under /v3/hooks, both with the token in the X-Auth-Token header. A small helper handles GET 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"]
API_HOST = f"https://api.bigcommerce.com/stores/{STORE_HASH}"

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

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

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

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

List orders modified since the last reconciliation pass

Call GET /v2/orders?min_date_modified=<ISO8601 last-checked timestamp>&limit=250&page=<n>, paginated, to get every order BigCommerce has touched since the last time this job ran successfully. For each order, keep id, status_id, status, and date_modified, that is all the decision needs.

step3.py
def orders_modified_since(last_checked_iso8601):
    page = 1
    while True:
        orders = bc_get("/v2/orders", {
            "min_date_modified": last_checked_iso8601,
            "limit": 250,
            "page": page,
        })
        if not orders:
            return
        for order in orders:
            yield order
        page += 1

def hook_is_active(scope="store/order/statusUpdated"):
    hooks = bc_get("/v3/hooks")
    for hook in hooks.get("data", []):
        if hook.get("scope") == scope:
            return hook.get("is_active", False)
    return False
step3.js
async function* ordersModifiedSince(lastCheckedIso8601) {
  let page = 1;
  while (true) {
    const orders = await bcGet("/v2/orders", {
      min_date_modified: lastCheckedIso8601,
      limit: 250,
      page,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function hookIsActive(scope = "store/order/statusUpdated") {
  const hooks = await bcGet("/v3/hooks");
  for (const hook of hooks.data || []) {
    if (hook.scope === scope) return Boolean(hook.is_active);
  }
  return false;
}
4

Diff against the last-known status, with one pure function

Keep the decision in its own function that takes your locally stored last-known status_id per order_id, plus the freshly fetched orders, and returns the list of mismatches. No local record for an order_id at all counts as a mismatch too, since it means this order's status change was never recorded locally in the first place.

diff.py
def diff_order_status(known_status_by_order_id, fetched_orders):
    mismatches = []
    for order in fetched_orders:
        order_id = order["id"]
        known = known_status_by_order_id.get(order_id)
        if known is None or known != order["status_id"]:
            mismatches.append({
                "order_id": order_id,
                "previous_known_status_id": known,
                "current_status_id": order["status_id"],
                "date_modified": order["date_modified"],
            })
    return mismatches
diff.js
export function diffOrderStatus(knownStatusByOrderId, fetchedOrders) {
  const mismatches = [];
  for (const order of fetchedOrders) {
    const orderId = order.id;
    const known = knownStatusByOrderId.has(orderId) ? knownStatusByOrderId.get(orderId) : null;
    if (known === null || known !== order.status_id) {
      mismatches.push({
        order_id: orderId,
        previous_known_status_id: known,
        current_status_id: order.status_id,
        date_modified: order.date_modified,
      });
    }
  }
  return mismatches;
}
5

Re-derive truth from BigCommerce, never write a status back

For every order_id flagged by the diff, the repair is to re-fetch GET /v2/orders/{id} and take its status_id, status, and date_modified as authoritative. There is no PUT /v2/orders/{id} here. BigCommerce's own record of the order status is already correct, the bug is only in the local shadow copy, so the fix is to update that local copy, guarded by the same DRY_RUN flag as everything else.

repair.py
def refetch_order(order_id):
    return bc_get(f"/v2/orders/{order_id}")
repair.js
async function refetchOrder(orderId) {
  return bcGet(`/v2/orders/${orderId}`);
}
6

Wire it together with a dry run guard

The loop lists orders modified since the last successful pass, diffs them against local state, re-fetches every flagged order to confirm the authoritative status_id, and, unless DRY_RUN is on, updates only the local last-known-status record. Any re-fetch that fails again is logged with the order id and timestamp so it is retried on the next pass instead of being silently dropped a second time. Run it on a schedule that fits how often webhooks fire for your store, once every 15 to 60 minutes is a reasonable start.

Run it safe

Always start with DRY_RUN=true. Never call PUT /v2/orders/{id} to set a status_id from this job, the merchant's real status is already correct in BigCommerce, only the local mirror is wrong. Writing a guessed status back to BigCommerce risks overwriting a status that has since moved on again.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever updates its own local shadow record, never BigCommerce's order status.

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

reconcile_order_status.py
"""Find BigCommerce order status changes dropped by a thin webhook payload.

The store/order/statusUpdated webhook's data object carries only
{"type":"order","id":<order_id>}, never the resulting status_id. Consumers are
required to make a follow-up GET /v2/orders/{id} to learn what actually
changed. If that follow-up GET fails, times out, hits a rate limit, or the app
crashes before it completes, the status change is dropped with nothing left to
retry from, because a webhook BigCommerce delivered successfully (200 OK) is
never resent even if your own internal follow-up call fails afterward. This
job keeps a local last-known status_id per order, lists every order modified
since the last successful pass, diffs their status_id against local state, and
re-fetches each mismatch from BigCommerce so a human or a re-sync can repair
the local shadow copy. It never writes a status back to BigCommerce. Run on a
schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/status-webhook-payload-missing-detail/
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_HOST = f"https://api.bigcommerce.com/stores/{STORE_HASH}"
RECONCILE_LOOKBACK_HOURS = int(os.environ.get("RECONCILE_LOOKBACK_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

STATUS_UPDATED_SCOPE = "store/order/statusUpdated"

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


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


def diff_order_status(known_status_by_order_id, fetched_orders):
    """Pure decision. No network, no side effects.

    For each order in fetched_orders (dict with at least id, status_id,
    date_modified), look up the locally stored last-known status_id. If there
    is no local record, or it disagrees with the order's current status_id,
    the order is a dropped update. Returns the list of all such mismatches,
    empty if everything is in sync.
    """
    mismatches = []
    for order in fetched_orders:
        order_id = order["id"]
        known = known_status_by_order_id.get(order_id)
        if known is None or known != order["status_id"]:
            mismatches.append({
                "order_id": order_id,
                "previous_known_status_id": known,
                "current_status_id": order["status_id"],
                "date_modified": order["date_modified"],
            })
    return mismatches


def orders_modified_since(last_checked_iso8601):
    page = 1
    while True:
        orders = bc_get(
            "/v2/orders",
            {
                "min_date_modified": last_checked_iso8601,
                "limit": 250,
                "page": page,
            },
        )
        if not orders:
            return
        for order in orders:
            yield order
        page += 1


def hook_is_active(scope=STATUS_UPDATED_SCOPE):
    hooks = bc_get("/v3/hooks")
    for hook in hooks.get("data", []):
        if hook.get("scope") == scope:
            return hook.get("is_active", False)
    return False


def refetch_order(order_id):
    return bc_get(f"/v2/orders/{order_id}")


def run(known_status_by_order_id=None, last_checked_iso8601=None):
    """known_status_by_order_id and last_checked_iso8601 would normally come
    from your own persistence layer (database, file, cache). Kept as
    parameters here so the wiring stays testable and swappable.
    """
    known_status_by_order_id = known_status_by_order_id or {}
    if last_checked_iso8601 is None:
        from datetime import datetime, timedelta, timezone
        cutoff = datetime.now(timezone.utc) - timedelta(hours=RECONCILE_LOOKBACK_HOURS)
        last_checked_iso8601 = cutoff.strftime("%Y-%m-%dT%H:%M:%S")

    if not hook_is_active():
        log.warning(
            "store/order/statusUpdated hook is not active. This explains a "
            "systemic gap, not just isolated follow-up GET failures."
        )

    fetched_orders = list(orders_modified_since(last_checked_iso8601))
    mismatches = diff_order_status(known_status_by_order_id, fetched_orders)

    repaired = 0
    still_failing = 0
    for mismatch in mismatches:
        order_id = mismatch["order_id"]
        try:
            order = refetch_order(order_id)
        except requests.RequestException as exc:
            log.error(
                "Re-fetch failed for order_id=%s at %s: %s",
                order_id, mismatch["date_modified"], exc,
            )
            still_failing += 1
            continue

        log.info(
            "order_id=%s previous_known_status_id=%s current_status_id=%s (%s)",
            order_id, mismatch["previous_known_status_id"], order.get("status_id"),
            "dry run" if DRY_RUN else "repairing local mirror",
        )
        if not DRY_RUN:
            known_status_by_order_id[order_id] = order.get("status_id")
        repaired += 1

    log.info(
        "Done. %d dropped update(s) found, %d %s, %d failed re-fetch and will retry next pass.",
        len(mismatches), repaired, "to repair" if DRY_RUN else "repaired", still_failing,
    )
    return known_status_by_order_id


if __name__ == "__main__":
    run()
reconcile-order-status.js
/**
 * Find BigCommerce order status changes dropped by a thin webhook payload.
 *
 * The store/order/statusUpdated webhook's data object carries only
 * {"type":"order","id":<order_id>}, never the resulting status_id. Consumers
 * are required to make a follow-up GET /v2/orders/{id} to learn what actually
 * changed. If that follow-up GET fails, times out, hits a rate limit, or the
 * app crashes before it completes, the status change is dropped with nothing
 * left to retry from, because a webhook BigCommerce delivered successfully
 * (200 OK) is never resent even if your own internal follow-up call fails
 * afterward. This job keeps a local last-known status_id per order, lists
 * every order modified since the last successful pass, diffs their status_id
 * against local state, and re-fetches each mismatch from BigCommerce so a
 * human or a re-sync can repair the local shadow copy. It never writes a
 * status back to BigCommerce. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/status-webhook-payload-missing-detail/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_HOST = `https://api.bigcommerce.com/stores/${STORE_HASH}`;
const RECONCILE_LOOKBACK_HOURS = Number(process.env.RECONCILE_LOOKBACK_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const STATUS_UPDATED_SCOPE = "store/order/statusUpdated";

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

/**
 * Pure decision. No network, no side effects.
 *
 * For each order in fetchedOrders (object with at least id, status_id,
 * date_modified), look up the locally stored last-known status_id. If there
 * is no local record, or it disagrees with the order's current status_id,
 * the order is a dropped update. Returns the list of all such mismatches,
 * empty if everything is in sync.
 */
export function diffOrderStatus(knownStatusByOrderId, fetchedOrders) {
  const mismatches = [];
  for (const order of fetchedOrders) {
    const orderId = order.id;
    const known = knownStatusByOrderId.has(orderId) ? knownStatusByOrderId.get(orderId) : null;
    if (known === null || known !== order.status_id) {
      mismatches.push({
        order_id: orderId,
        previous_known_status_id: known,
        current_status_id: order.status_id,
        date_modified: order.date_modified,
      });
    }
  }
  return mismatches;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_HOST}${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* ordersModifiedSince(lastCheckedIso8601) {
  let page = 1;
  while (true) {
    const orders = await bcGet("/v2/orders", {
      min_date_modified: lastCheckedIso8601,
      limit: 250,
      page,
    });
    if (!orders.length) return;
    for (const order of orders) yield order;
    page += 1;
  }
}

async function hookIsActive(scope = STATUS_UPDATED_SCOPE) {
  const hooks = await bcGet("/v3/hooks");
  for (const hook of hooks.data || []) {
    if (hook.scope === scope) return Boolean(hook.is_active);
  }
  return false;
}

async function refetchOrder(orderId) {
  return bcGet(`/v2/orders/${orderId}`);
}

export async function run({ knownStatusByOrderId = new Map(), lastCheckedIso8601 } = {}) {
  if (!lastCheckedIso8601) {
    const cutoff = new Date(Date.now() - RECONCILE_LOOKBACK_HOURS * 60 * 60 * 1000);
    lastCheckedIso8601 = cutoff.toISOString().slice(0, 19);
  }

  if (!(await hookIsActive())) {
    console.warn(
      "store/order/statusUpdated hook is not active. This explains a systemic gap, not just isolated follow-up GET failures."
    );
  }

  const fetchedOrders = [];
  for await (const order of ordersModifiedSince(lastCheckedIso8601)) fetchedOrders.push(order);

  const mismatches = diffOrderStatus(knownStatusByOrderId, fetchedOrders);

  let repaired = 0;
  let stillFailing = 0;
  for (const mismatch of mismatches) {
    const orderId = mismatch.order_id;
    let order;
    try {
      order = await refetchOrder(orderId);
    } catch (err) {
      console.error(`Re-fetch failed for order_id=${orderId} at ${mismatch.date_modified}: ${err.message}`);
      stillFailing += 1;
      continue;
    }

    console.log(
      `order_id=${orderId} previous_known_status_id=${mismatch.previous_known_status_id} ` +
      `current_status_id=${order.status_id} (${DRY_RUN ? "dry run" : "repairing local mirror"})`
    );
    if (!DRY_RUN) knownStatusByOrderId.set(orderId, order.status_id);
    repaired += 1;
  }

  console.log(
    `Done. ${mismatches.length} dropped update(s) found, ${repaired} ${DRY_RUN ? "to repair" : "repaired"}, ` +
    `${stillFailing} failed re-fetch and will retry next pass.`
  );
  return knownStatusByOrderId;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The diff rule is the part most worth testing, because it decides which orders get treated as dropped updates. Because diff_order_status takes only plain values and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in constructed dicts and checks the answer.

test_status_reconciler.py
from reconcile_order_status import diff_order_status


def order(id_=1, status_id=11, date_modified="2026-07-10T12:00:00"):
    return {"id": id_, "status_id": status_id, "date_modified": date_modified}


def test_no_local_record_is_a_mismatch():
    result = diff_order_status({}, [order(id_=1, status_id=11)])
    assert result == [{
        "order_id": 1,
        "previous_known_status_id": None,
        "current_status_id": 11,
        "date_modified": "2026-07-10T12:00:00",
    }]


def test_matching_status_is_a_no_op():
    known = {1: 11}
    result = diff_order_status(known, [order(id_=1, status_id=11)])
    assert result == []


def test_stale_status_is_a_mismatch():
    known = {1: 7}
    result = diff_order_status(known, [order(id_=1, status_id=11)])
    assert result == [{
        "order_id": 1,
        "previous_known_status_id": 7,
        "current_status_id": 11,
        "date_modified": "2026-07-10T12:00:00",
    }]


def test_empty_fetched_orders_returns_empty_list():
    assert diff_order_status({1: 11}, []) == []


def test_mixed_batch_only_flags_the_mismatches():
    known = {1: 11, 2: 7}
    fetched = [order(id_=1, status_id=11), order(id_=2, status_id=10), order(id_=3, status_id=5)]
    result = diff_order_status(known, fetched)
    order_ids = sorted(m["order_id"] for m in result)
    assert order_ids == [2, 3]
reconcile-order-status.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffOrderStatus } from "./reconcile-order-status.js";

const order = ({ id = 1, status_id = 11, date_modified = "2026-07-10T12:00:00" } = {}) => ({
  id, status_id, date_modified,
});

test("no local record is a mismatch", () => {
  const result = diffOrderStatus(new Map(), [order({ id: 1, status_id: 11 })]);
  assert.deepEqual(result, [{
    order_id: 1,
    previous_known_status_id: null,
    current_status_id: 11,
    date_modified: "2026-07-10T12:00:00",
  }]);
});

test("matching status is a no-op", () => {
  const known = new Map([[1, 11]]);
  const result = diffOrderStatus(known, [order({ id: 1, status_id: 11 })]);
  assert.deepEqual(result, []);
});

test("stale status is a mismatch", () => {
  const known = new Map([[1, 7]]);
  const result = diffOrderStatus(known, [order({ id: 1, status_id: 11 })]);
  assert.deepEqual(result, [{
    order_id: 1,
    previous_known_status_id: 7,
    current_status_id: 11,
    date_modified: "2026-07-10T12:00:00",
  }]);
});

test("empty fetched orders returns empty list", () => {
  assert.deepEqual(diffOrderStatus(new Map([[1, 11]]), []), []);
});

test("mixed batch only flags the mismatches", () => {
  const known = new Map([[1, 11], [2, 7]]);
  const fetched = [order({ id: 1, status_id: 11 }), order({ id: 2, status_id: 10 }), order({ id: 3, status_id: 5 })];
  const result = diffOrderStatus(known, fetched);
  const orderIds = result.map((m) => m.order_id).sort();
  assert.deepEqual(orderIds, [2, 3]);
});

Case studies

Rate limited during a sale

The store whose follow-up GETs started 429ing at peak traffic

A store running a flash sale saw a burst of order status changes fire in a short window. Every webhook arrived and was acknowledged with 200 OK, but the app's follow-up GET calls to fetch each order's real status started hitting BigCommerce's rate limit. Those failed lookups were logged and forgotten, since nothing about the webhook itself had failed, so nothing retried.

The reconciler now runs every 15 minutes during known high-traffic windows. It lists everything modified since the last pass, catches every order whose status_id never matched what was recorded locally, and re-fetches just those. The gap closes within one run instead of sitting unnoticed until a customer asks where their shipment confirmation went.

Crash mid-deploy

The deploy that landed between webhook receipt and the follow-up call

A routine deploy restarted the app's webhook worker at an unlucky moment, right after a statusUpdated event had been acknowledged but before the follow-up GET to fetch the new status had completed. BigCommerce considered that delivery done. The app never got a second chance at it.

Because the reconciler does not depend on the webhook stream at all, it caught the order on its very next scheduled pass, saw the status_id had never been recorded locally, re-fetched the order directly from BigCommerce, and closed the gap without anyone needing to know a deploy had even caused it.

What good looks like

After this runs on a schedule, a dropped status update is never more than one reconciliation pass away from being found and reported, whether the follow-up GET failed from a timeout, a rate limit, or a crash mid-request. The reconciler never guesses at a status and never writes one back to BigCommerce, it only ever repairs its own local shadow copy from the order record BigCommerce already has.

FAQ

Why does the store/order/statusUpdated webhook not just tell me the new status?

BigCommerce webhook payloads are deliberately thin. The data object for store/order/statusUpdated carries only {"type":"order","id":<order_id>}, BigCommerce's own docs describe it as containing only the order id. The webhook exists to tell you something changed on that order, not what changed. You are expected to make a follow-up GET /v2/orders/{id} to learn the actual status_id.

If the follow-up GET fails, will BigCommerce resend the webhook?

No. BigCommerce only retries a webhook delivery when your endpoint itself fails to return a 2xx response, and it deactivates the hook after about 48 hours of failures. If your endpoint returns 200 OK and only your own internal follow-up GET to fetch the order fails afterward, that is invisible to BigCommerce. From its perspective the delivery already succeeded, so nothing is ever retried.

How do I find status changes that were dropped this way?

Do not rely on webhook delivery history, BigCommerce does not expose it. Instead, keep a local last-known status_id per order_id, then periodically call GET /v2/orders?min_date_modified=... to list orders modified since your last check. Compare each returned status_id against your local record. Any order with no local record, or a mismatched status_id, is a dropped update. Also confirm the hook is still is_active:true via GET /v3/hooks.

Related field notes

Citations

On the problem:

  1. BigCommerce Dev Center: the store/order/statusUpdated webhook model and its payload shape. developer.bigcommerce.com store/order/statusUpdated
  2. BigCommerce Developer Center: webhooks overview, scope, data, hash, and producer fields. developer.bigcommerce.com webhooks overview
  3. BigCommerce Support Community: an order webhook firing but the follow-up order fetch returning an unexpected or stale status. support.bigcommerce.com order created webhook callback status_id issue

On the solution:

  1. BigCommerce Docs: webhooks overview, the event payload structure, and how producer/scope/data/hash fit together. docs.bigcommerce.com webhooks overview
  2. BigCommerce Docs: Get Order, including status_id, status, and date_modified fields used for reconciliation. docs.bigcommerce.com get order
  3. BigCommerce Docs: Webhooks v3, listing and updating hooks, and the is_active field. docs.bigcommerce.com webhooks v3

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 gap you would have otherwise missed?

If this saved you from silently losing order status changes, 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