Reconciler Webhooks
Missed webhooks with no backfill
Your endpoint was down for a deploy, or it started returning 500s, or a certificate expired. BigCommerce kept trying to deliver, on a backoff schedule, for about 48 hours across up to 11 attempts. Then it gave up for good, flipped the hook's is_active to false, and emailed the address on file. Every store_order_created and store_order_status_updated event from that window is gone. There is no dead letter queue, no event log, and no replay button on BigCommerce's side. Here is why that gap is permanent and a small script that finds it and repairs it by asking the REST API what actually happened.
BigCommerce webhook delivery is at-least-once, but only while your endpoint is reachable. If it returns non-2xx or times out, BigCommerce retries on a backoff schedule for roughly 48 hours and up to 11 attempts, then permanently stops, sets is_active to false on GET /v3/hooks, and emails the app's registered contact. There is no replay API. Run a small Python or Node.js script that checks is_active to confirm the gap, scans GET /v2/orders?min_date_modified=...&max_date_modified=... across the outage window, compares each order's status_id and date_modified against what your app already has stored, and replays anything missing or stale through your own idempotent order sync handler. It never calls a destructive BigCommerce write, and the only API write involved is reactivating the hook with PUT /v3/hooks/{hook_id} once the backfill is confirmed. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce does try hard to deliver a webhook. If your endpoint is briefly unreachable, it keeps retrying with increasing delays, and most short outages never lose a single event. The trouble starts when the outage runs long: a deploy that hangs, a crashed process nobody noticed, a TLS certificate that expired over a weekend. BigCommerce keeps retrying, but only for about 48 hours and up to 11 attempts. After that it stops permanently.
When it stops, it does not queue the missed events anywhere you can get to. It does not keep a log you can query. It flips the subscription's is_active field to false and sends an email to whoever is registered as the app's contact. From that point forward, nothing fires for that scope until you notice, fix the endpoint, and recreate or reactivate the hook. Every order created or every status change that happened during the outage, and after the retries ran out, simply never reaches your app unless you go get it yourself from the REST API.
Why it happens
BigCommerce's webhook system is deliberately simple. It is at-least-once delivery with a bounded retry window, not a durable queue that waits for you forever. A few common ways stores end up with a real gap:
- A deploy that took the endpoint down for longer than expected, or a bad release that made it return 500 on every request until someone noticed.
- A crashed worker process or a database outage on the app's side that answered every retry with a timeout or a non-2xx response.
- An expired TLS certificate on the receiving endpoint, which fails the connection outright and looks identical to downtime from BigCommerce's point of view.
- A quiet weekend or holiday outage where nobody was watching alerts, so the full 48 hour, 11 attempt retry budget ran out before anyone fixed the endpoint.
This is a common source of confusion because the failure is invisible until someone checks. Orders keep flowing through the storefront the whole time. Nothing in the BigCommerce control panel screams that a webhook died, only the is_active flag on that one hook and an email that is easy to miss in a shared inbox. See the citations at the end for the exact references on the retry schedule and the webhook events reference.
There is no way to ask BigCommerce to resend what it already gave up on. So the safe pattern is not "wait and hope" or "beg support for a replay." It is "treat the REST API as the source of truth and rebuild the missing events yourself." We do that by scanning orders modified during the outage window and diffing each one against what our app already recorded, then replaying only the ones that are actually missing or stale through the same handler a real webhook would have triggered.
The fix, as a flow
We do not try to resurrect the original webhook payloads. We add a job that confirms the gap by reading the hook's is_active field, works out the outage window from our own last known good timestamp, pulls every order BigCommerce says was modified in that window, and for each one asks a pure function whether our local record is missing or stale. Anything flagged gets replayed through the app's own order sync handler, guarded by a dry run flag, and only once that is done do we flip the hook back to active.
Build it step by step
Get a BigCommerce API access token
Create an API account in your BigCommerce control panel under Settings, API, and generate a token with the Orders read scope and the Webhooks read and modify scopes, since the script both scans orders and reactivates the disabled hook at the end. Keep the store hash and the token in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export WINDOW_START="2026-07-05T00:00:00+00:00"
export WINDOW_END="2026-07-07T00:00:00+00:00"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export WINDOW_START="2026-07-05T00:00:00+00:00"
export WINDOW_END="2026-07-07T00:00:00+00:00"
export DRY_RUN="true" // start safe, change to false to write
Talk to the BigCommerce REST API
Every call carries your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper sends the request, raises on a non-2xx response, and returns the parsed body. We reuse this helper to read the hook status, scan orders, and later reactivate the hook.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
Confirm the gap and scan the outage window
Call GET /v3/hooks and look at each subscription's is_active field. A false value confirms BigCommerce gave up retrying that hook. Then page through GET /v2/orders?min_date_modified=...&max_date_modified=...&limit=250&page=n across the outage window. This catches both new orders created during the gap and any status_id transition, such as Awaiting Fulfillment moving to Shipped or Cancelled, because BigCommerce reports the order as modified either way.
def hooks_needing_backfill():
hooks = bc("GET", "/v3/hooks")["data"]
return [h for h in hooks if h.get("is_active") is False]
def orders_in_window(window_start, window_end):
page = 1
while True:
rows = bc(
"GET",
f"/v2/orders?min_date_modified={window_start}&max_date_modified={window_end}"
f"&limit=250&page={page}",
)
if not rows:
return
for row in rows:
yield row
page += 1
async function hooksNeedingBackfill() {
const hooks = (await bc("GET", "/v3/hooks")).data;
return hooks.filter((h) => h.is_active === false);
}
async function* ordersInWindow(windowStart, windowEnd) {
let page = 1;
while (true) {
const rows = await bc(
"GET",
`/v2/orders?min_date_modified=${windowStart}&max_date_modified=${windowEnd}` +
`&limit=250&page=${page}`
);
if (!rows || rows.length === 0) return;
for (const row of rows) yield row;
page++;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the local state we have for an order, the freshly fetched order from BigCommerce, and the outage window, and returns whether that order was missed. It is true when we never saw the order at all, when its status_id has moved on since our last record, or when its date_modified is newer than what we have stored, as long as the order's own date_modified falls inside the window. A pure function like this needs no network to test, which we do later.
def is_order_missed(local_state, remote_order, window_start, window_end):
"""Pure decision function. No network calls.
local_state: {"status_id": int, "date_modified": str} | None
remote_order: {"status_id": int, "date_modified": str, ...}
window_start, window_end: ISO 8601 strings
"""
remote_modified = remote_order["date_modified"]
if not (window_start <= remote_modified <= window_end):
return False
if local_state is None:
return True
if local_state["status_id"] != remote_order["status_id"]:
return True
return local_state["date_modified"] < remote_modified
/**
* Pure decision function. No network calls.
* localState: { statusId: number, dateModified: string } | null
* remoteOrder: { statusId: number, dateModified: string, ... }
* windowStart, windowEnd: ISO 8601 strings
*/
export function isOrderMissed(localState, remoteOrder, windowStart, windowEnd) {
const remoteModified = remoteOrder.dateModified;
if (!(windowStart <= remoteModified && remoteModified <= windowEnd)) return false;
if (localState === null || localState === undefined) return true;
if (localState.statusId !== remoteOrder.statusId) return true;
return localState.dateModified < remoteModified;
}
Replay through your own handler, then reactivate the hook
For each order flagged as missed, fetch the fresh state your handler actually needs, such as GET /v2/orders/{id}, plus /products, /shipments, and /transactions as needed, and invoke the same idempotent order sync handler a real webhook would have triggered. This is a reconciliation replay, not a destructive write. Only after the backlog is cleared does the script call PUT /v3/hooks/{hook_id} with {"is_active": true}, the one additive write involved, so the hook resumes receiving live events.
def replay_order(order_id, sync_handler):
order = bc("GET", f"/v2/orders/{order_id}")
products = bc("GET", f"/v2/orders/{order_id}/products") or []
shipments = bc("GET", f"/v2/orders/{order_id}/shipments") or []
transactions = bc("GET", f"/v2/orders/{order_id}/transactions") or []
sync_handler(order, products, shipments, transactions)
def reactivate_hook(hook_id):
return bc("PUT", f"/v3/hooks/{hook_id}", json={"is_active": True})
async function replayOrder(orderId, syncHandler) {
const order = await bc("GET", `/v2/orders/${orderId}`);
const products = (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
const shipments = (await bc("GET", `/v2/orders/${orderId}/shipments`)) || [];
const transactions = (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
await syncHandler(order, products, shipments, transactions);
}
async function reactivateHook(hookId) {
return bc("PUT", `/v3/hooks/${hookId}`, { is_active: true });
}
Wire it together with a dry run guard
The loop scans the window, runs the pure decision function against each order, and only invokes the sync handler and reactivates the hook when DRY_RUN is off. Leave DRY_RUN=true on the first runs so the script only logs the order_id, the missed status_id, and the action it would run. Read that list, confirm it matches what you expect from the outage, then switch it off and let it repair the backlog and turn the hook back on.
Always start with DRY_RUN=true. The replay only reads from BigCommerce and re-runs your own internal handler, it never calls a destructive BigCommerce write. The single write on the BigCommerce side is the additive reactivation of the hook once you are confident the backfill is complete.
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 only ever writes an additive is_active: true back to BigCommerce, never a change to an order.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Reconcile BigCommerce order events missed during a deactivated webhook.
BigCommerce webhook delivery is at-least-once, but it is not durable forever.
If your endpoint returns non-2xx or times out, BigCommerce retries on a
backoff schedule for roughly 48 hours across up to 11 attempts, then gives up,
sets the hook's is_active to false, and emails the app's registered contact.
There is no dead letter queue, event log, or replay API. This scans orders
modified during the outage window, diffs each one against locally stored
state, and replays anything missing or stale through the app's own idempotent
order sync handler using freshly fetched order data. It never calls a
destructive BigCommerce write. The only write is reactivating the hook once
the backfill is confirmed. Run once after a confirmed outage. Safe to run
again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_missed_webhooks")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
WINDOW_START = os.environ["WINDOW_START"]
WINDOW_END = os.environ["WINDOW_END"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
def is_order_missed(local_state, remote_order, window_start, window_end):
"""Pure decision function. No network calls.
local_state: {"status_id": int, "date_modified": str} | None
remote_order: {"status_id": int, "date_modified": str, ...}
window_start, window_end: ISO 8601 strings
"""
remote_modified = remote_order["date_modified"]
if not (window_start <= remote_modified <= window_end):
return False
if local_state is None:
return True
if local_state["status_id"] != remote_order["status_id"]:
return True
return local_state["date_modified"] < remote_modified
def hooks_needing_backfill():
hooks = bc("GET", "/v3/hooks")["data"]
return [h for h in hooks if h.get("is_active") is False]
def orders_in_window():
page = 1
while True:
rows = bc(
"GET",
f"/v2/orders?min_date_modified={WINDOW_START}&max_date_modified={WINDOW_END}"
f"&limit=250&page={page}",
)
if not rows:
return
for row in rows:
yield row
page += 1
def load_local_state(order_id):
"""Look up your own app's last recorded state for this order. Replace with your storage layer."""
return None
def replay_order(order_id, sync_handler):
order = bc("GET", f"/v2/orders/{order_id}")
products = bc("GET", f"/v2/orders/{order_id}/products") or []
shipments = bc("GET", f"/v2/orders/{order_id}/shipments") or []
transactions = bc("GET", f"/v2/orders/{order_id}/transactions") or []
sync_handler(order, products, shipments, transactions)
def reactivate_hook(hook_id):
return bc("PUT", f"/v3/hooks/{hook_id}", json={"is_active": True})
def default_sync_handler(order, products, shipments, transactions):
log.info("Would sync order #%s status_id=%s", order.get("id"), order.get("status_id"))
def run(sync_handler=default_sync_handler):
replayed = 0
for row in orders_in_window():
local_state = load_local_state(row["id"])
remote_order = {"status_id": int(row["status_id"]), "date_modified": row["date_modified"]}
if not is_order_missed(local_state, remote_order, WINDOW_START, WINDOW_END):
continue
log.warning(
"Order #%s missed. status_id=%s. %s",
row["id"], row["status_id"],
"would replay" if DRY_RUN else "replaying",
)
if not DRY_RUN:
replay_order(row["id"], sync_handler)
replayed += 1
if not DRY_RUN:
for hook in hooks_needing_backfill():
log.info("Reactivating hook %s", hook["id"])
reactivate_hook(hook["id"])
log.info("Done. %d order(s) %s.", replayed, "to replay" if DRY_RUN else "replayed")
if __name__ == "__main__":
run()
/**
* Reconcile BigCommerce order events missed during a deactivated webhook.
*
* BigCommerce webhook delivery is at-least-once, but it is not durable forever.
* If your endpoint returns non-2xx or times out, BigCommerce retries on a
* backoff schedule for roughly 48 hours across up to 11 attempts, then gives up,
* sets the hook's is_active to false, and emails the app's registered contact.
* There is no dead letter queue, event log, or replay API. This scans orders
* modified during the outage window, diffs each one against locally stored
* state, and replays anything missing or stale through the app's own idempotent
* order sync handler using freshly fetched order data. It never calls a
* destructive BigCommerce write. The only write is reactivating the hook once
* the backfill is confirmed. Run once after a confirmed outage.
*
* Guide: https://www.allanninal.dev/bigcommerce/missed-webhooks-with-no-backfill/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const WINDOW_START = process.env.WINDOW_START || "1970-01-01T00:00:00+00:00";
const WINDOW_END = process.env.WINDOW_END || "1970-01-02T00:00:00+00:00";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* Pure decision function. No network calls.
* localState: { statusId: number, dateModified: string } | null
* remoteOrder: { statusId: number, dateModified: string, ... }
* windowStart, windowEnd: ISO 8601 strings
*/
export function isOrderMissed(localState, remoteOrder, windowStart, windowEnd) {
const remoteModified = remoteOrder.dateModified;
if (!(windowStart <= remoteModified && remoteModified <= windowEnd)) return false;
if (localState === null || localState === undefined) return true;
if (localState.statusId !== remoteOrder.statusId) return true;
return localState.dateModified < remoteModified;
}
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function hooksNeedingBackfill() {
const hooks = (await bc("GET", "/v3/hooks")).data;
return hooks.filter((h) => h.is_active === false);
}
async function* ordersInWindow() {
let page = 1;
while (true) {
const rows = await bc(
"GET",
`/v2/orders?min_date_modified=${WINDOW_START}&max_date_modified=${WINDOW_END}` +
`&limit=250&page=${page}`
);
if (!rows || rows.length === 0) return;
for (const row of rows) yield row;
page++;
}
}
function loadLocalState(_orderId) {
// Look up your own app's last recorded state for this order. Replace with your storage layer.
return null;
}
async function replayOrder(orderId, syncHandler) {
const order = await bc("GET", `/v2/orders/${orderId}`);
const products = (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
const shipments = (await bc("GET", `/v2/orders/${orderId}/shipments`)) || [];
const transactions = (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
await syncHandler(order, products, shipments, transactions);
}
async function reactivateHook(hookId) {
return bc("PUT", `/v3/hooks/${hookId}`, { is_active: true });
}
async function defaultSyncHandler(order) {
console.log(`Would sync order #${order.id} status_id=${order.status_id}`);
}
export async function run(syncHandler = defaultSyncHandler) {
let replayed = 0;
for await (const row of ordersInWindow()) {
const localState = loadLocalState(row.id);
const remoteOrder = { statusId: Number(row.status_id), dateModified: row.date_modified };
if (!isOrderMissed(localState, remoteOrder, WINDOW_START, WINDOW_END)) continue;
console.warn(
`Order #${row.id} missed. status_id=${row.status_id}. ${DRY_RUN ? "would replay" : "replaying"}`
);
if (!DRY_RUN) await replayOrder(row.id, syncHandler);
replayed++;
}
if (!DRY_RUN) {
for (const hook of await hooksNeedingBackfill()) {
console.log(`Reactivating hook ${hook.id}`);
await reactivateHook(hook.id);
}
}
console.log(`Done. ${replayed} order(s) ${DRY_RUN ? "to replay" : "replayed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The missed-order decision is the part most worth testing, because it decides which orders get replayed into your own systems. Because we kept is_order_missed pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from backfill_missed_webhooks import is_order_missed
WINDOW_START = "2026-07-05T00:00:00+00:00"
WINDOW_END = "2026-07-07T00:00:00+00:00"
def remote(status_id=11, date_modified="2026-07-06T00:00:00+00:00"):
return {"status_id": status_id, "date_modified": date_modified}
def local(status_id=11, date_modified="2026-07-06T00:00:00+00:00"):
return {"status_id": status_id, "date_modified": date_modified}
def test_never_seen_order_is_missed():
assert is_order_missed(None, remote(), WINDOW_START, WINDOW_END) is True
def test_status_changed_is_missed():
stale = local(status_id=11)
fresh = remote(status_id=2)
assert is_order_missed(stale, fresh, WINDOW_START, WINDOW_END) is True
def test_local_older_than_remote_is_missed():
stale = local(date_modified="2026-07-05T12:00:00+00:00")
fresh = remote(date_modified="2026-07-06T12:00:00+00:00")
assert is_order_missed(stale, fresh, WINDOW_START, WINDOW_END) is True
def test_matching_local_state_not_missed():
same = local(status_id=2, date_modified="2026-07-06T00:00:00+00:00")
fresh = remote(status_id=2, date_modified="2026-07-06T00:00:00+00:00")
assert is_order_missed(same, fresh, WINDOW_START, WINDOW_END) is False
def test_outside_window_not_missed():
outside = remote(date_modified="2026-07-08T00:00:00+00:00")
assert is_order_missed(None, outside, WINDOW_START, WINDOW_END) is False
def test_before_window_not_missed():
before = remote(date_modified="2026-07-04T00:00:00+00:00")
assert is_order_missed(None, before, WINDOW_START, WINDOW_END) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOrderMissed } from "./backfill-missed-webhooks.js";
const WINDOW_START = "2026-07-05T00:00:00+00:00";
const WINDOW_END = "2026-07-07T00:00:00+00:00";
const remote = (over = {}) => ({ statusId: 11, dateModified: "2026-07-06T00:00:00+00:00", ...over });
const local = (over = {}) => ({ statusId: 11, dateModified: "2026-07-06T00:00:00+00:00", ...over });
test("never seen order is missed", () => {
assert.equal(isOrderMissed(null, remote(), WINDOW_START, WINDOW_END), true);
});
test("status changed is missed", () => {
const stale = local({ statusId: 11 });
const fresh = remote({ statusId: 2 });
assert.equal(isOrderMissed(stale, fresh, WINDOW_START, WINDOW_END), true);
});
test("local older than remote is missed", () => {
const stale = local({ dateModified: "2026-07-05T12:00:00+00:00" });
const fresh = remote({ dateModified: "2026-07-06T12:00:00+00:00" });
assert.equal(isOrderMissed(stale, fresh, WINDOW_START, WINDOW_END), true);
});
test("matching local state is not missed", () => {
const same = local({ statusId: 2, dateModified: "2026-07-06T00:00:00+00:00" });
const fresh = remote({ statusId: 2, dateModified: "2026-07-06T00:00:00+00:00" });
assert.equal(isOrderMissed(same, fresh, WINDOW_START, WINDOW_END), false);
});
test("outside window is not missed", () => {
const outside = remote({ dateModified: "2026-07-08T00:00:00+00:00" });
assert.equal(isOrderMissed(null, outside, WINDOW_START, WINDOW_END), false);
});
test("before window is not missed", () => {
const before = remote({ dateModified: "2026-07-04T00:00:00+00:00" });
assert.equal(isOrderMissed(null, before, WINDOW_START, WINDOW_END), false);
});
Case studies
The deploy that hung over a holiday weekend
A merchant's fulfillment app pushed a release late on a Friday that left its webhook endpoint returning 502s. Nobody was watching alerts over the long weekend, so BigCommerce's full 48 hour retry budget ran out before Monday morning, and the hook silently flipped to is_active: false. Dozens of orders shipped over those three days with no fulfillment record ever created downstream.
Running the backfill script against the outage window caught every one of those orders in a single pass. Each missed order replayed through the same sync handler the webhook would have called, and the hook was reactivated automatically once the backlog cleared, so Monday's live traffic picked up exactly where it should have.
The TLS certificate nobody renewed in time
A smaller store's integration ran on a self-managed server whose TLS certificate expired without an alert firing. Every retry attempt failed the handshake before it ever reached the app, which looked identical to a downed server from BigCommerce's side. By the time someone noticed order counts looked off, the retry window had long since closed and the hook was deactivated.
Because the store had been recording its own status_id and date_modified per order all along, the diff against the fresh REST API pull was fast and precise. Only orders that had actually changed during the gap got replayed, and the rest were left untouched.
After an outage, a single reconciliation run tells you exactly which orders BigCommerce could not deliver, replays each one through the same logic a live webhook would have triggered, and turns the hook back on once the backlog is clear. Nothing about the storefront or the order data is touched by the script itself, only your own downstream systems catch up. The gap stops being a mystery discovered weeks later and becomes a routine, testable cleanup step.
FAQ
Does BigCommerce replay webhooks after my endpoint comes back up?
No. BigCommerce retries a failing webhook on a backoff schedule for roughly 48 hours across up to 11 attempts. Once that window elapses it permanently stops, sets the hook's is_active field to false, and emails the app's registered contact. There is no dead letter queue, no event log, and no replay missed events API, so any store_order_created or store_order_status_updated events from the outage are gone unless your app reconstructs them itself.
How do I know I missed webhook events on BigCommerce?
Call GET /v3/hooks with your X-Auth-Token and check each subscription's is_active field. A value of false means BigCommerce exhausted its retries and gave up, which confirms a gap starting at that hook's last successful delivery. Compare that timestamp against your own locally recorded webhook receive times to pin down the outage window.
How do I safely backfill orders after a BigCommerce webhook was deactivated?
Scan GET /v2/orders with min_date_modified and max_date_modified set to the outage window, paginated with limit and page. For every order returned, compare its status_id and date_modified against what your app already has stored. Anything missing or stale gets replayed through your own idempotent order sync handler using freshly fetched order data, never through a destructive BigCommerce write. Guard the whole thing with a DRY_RUN flag and only reactivate the hook with PUT /v3/hooks/{hook_id} once the backfill is confirmed.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Webhooks Overview, retry schedule and deactivation. developer.bigcommerce.com/docs/integrations/webhooks
- BigCommerce Help Center: How to avoid webhooks deactivation, it happens periodically. support.bigcommerce.com webhooks deactivation question
- BigCommerce Developer Center: Webhook Events reference. developer.bigcommerce.com/api-docs/store-management/webhooks/webhook-events
On the solution:
- BigCommerce Developer Center: Orders (REST Management). developer.bigcommerce.com/docs/rest-management/orders
- BigCommerce API Reference: List Orders, min_date_modified and max_date_modified. developer.bigcommerce.com/docs/rest-management/orders#get-all-orders
- BigCommerce Developer Center: Webhooks (REST Management), is_active and reactivation. developer.bigcommerce.com/docs/rest-management/webhooks
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, inventory, webhooks, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this recover a missed order backlog?
If this saved you from a silent gap in fulfillment or reporting, 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