Reconciler Webhooks
Webhook deactivated after failures
Order and cart events used to show up in your app right away. Now an ERP sync is stale, or a fulfillment queue is empty, and nobody can say why. Nothing errored in the store admin. The webhook just quietly stopped. BigCommerce gave up retrying it a while back, flipped it off, and emailed an address nobody was watching. Here is why that happens and a small script that finds the deactivated or missing hooks and repairs them without flooding a still-broken destination.
BigCommerce retries a failed webhook delivery, any response that is not HTTP 2xx, including timeouts and TLS errors, with exponential backoff for up to 11 attempts spanning roughly 48 hours. If your destination never returns a 2xx in that window, BigCommerce sets is_active to false on that hook and emails the app's registered address, permanently pausing delivery until someone notices. A hook can also be auto deactivated after 90 days with zero triggered events. Run a small Python or Node.js script that lists every hook with GET /v3/hooks, diffs it against your app's expected scope and destination pairs, health checks the destination, then reactivates with PUT /v3/hooks/{id} or recreates a missing hook with POST /v3/hooks. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce webhooks are fire and forget from the store's point of view. When an order, product, or cart event happens, BigCommerce POSTs it to your registered destination and expects a 2xx response back. If your endpoint is down, slow, redeploying, or returns a 500, that single delivery fails, and BigCommerce retries it on a backoff schedule for up to about 11 attempts over roughly two days.
The trouble starts when the destination never recovers inside that window. After the last retry fails, BigCommerce does not keep trying forever. It sets is_active to false on that hook record and sends an email to whatever address is registered for the app. If nobody is reading that inbox, or the message lands in a spam folder, the hook just stays off. There is no error in the merchant's storefront, nothing in the BigCommerce admin calls it out, and the API does not expose a delivery attempt or failure log you could check after the fact. The event gap is invisible until a downstream system, an ERP sync, an inventory feed, a fulfillment queue, is found to be stale.
Why it happens
The webhook dispatcher and the app's destination are two independent systems, and nothing forces someone to notice when the link between them breaks. A few common ways stores end up with a dead hook:
- The app's endpoint goes down during a deploy, a host migration, or an expired TLS certificate, and stays down long enough that all 11 retry attempts across roughly 48 hours land on a non-2xx response or a timeout.
- The destination briefly returns 5xx or 429 under load for the exact window BigCommerce is retrying, so every attempt fails even though the service is generally healthy.
- The hook simply never fires for 90 days, for example a low-traffic scope like
store/cart/abandonedon a quiet store, and BigCommerce auto deactivates it for inactivity rather than failure. - The notification email about the deactivation goes to an address that changed, is unmonitored, or is filtered as noise, so the one signal BigCommerce does send never reaches a person who can act.
This is a common source of confusion because BigCommerce does not expose a delivery attempt or failure log through the API. You cannot ask "why did this fail" the way you can for many other platforms. The only way to know a hook is off, or missing entirely after a past cleanup, is to explicitly poll GET /v3/hooks and check is_active, and the only way to know why it failed is to look at your own app's inbound request log, not a BigCommerce endpoint. See the citations at the end for the exact references.
A deactivated hook does not tell you whether the destination is fixed yet. So the safe pattern is not "flip every inactive hook back on." It is "confirm the destination is healthy first, then reactivate, and treat a completely missing hook as a fresh creation, never an undelete." We do that with a synthetic health check before any PUT, and a plain POST /v3/hooks for anything that vanished, because BigCommerce assigns a brand new id and the old one is gone for good.
The fix, as a flow
We do not touch live order processing. We add a job that lists the hooks BigCommerce actually has, compares that against the scope and destination pairs the app is supposed to own, and splits the gaps into two buckets: hooks that exist but are inactive, and hooks that are missing entirely. Inactive hooks get a health check before they are turned back on. Missing hooks get recreated from scratch.
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 Webhooks scope, read and write. Keep the store hash and the token in environment variables, never in the file. Set your app's expected manifest of scope and destination pairs alongside it.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export WEBHOOK_DESTINATION="https://app.example.com/webhooks/bigcommerce"
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 WEBHOOK_DESTINATION="https://app.example.com/webhooks/bigcommerce"
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 list hooks, reactivate one, and recreate a missing one.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
return r.json() if r.content else None
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
List every registered hook, paginated
Call GET /v3/hooks and walk meta.pagination until there is no next page. Read back id, scope, destination, and is_active for each hook, since those four fields are everything the decision function needs.
def list_hooks():
page = 1
hooks = []
while True:
body = bc("GET", f"/v3/hooks?page={page}&limit=50")
rows = (body or {}).get("data", [])
if not rows:
return hooks
for row in rows:
hooks.append({
"id": row["id"],
"scope": row["scope"],
"destination": row["destination"],
"is_active": bool(row["is_active"]),
})
pagination = (body.get("meta") or {}).get("pagination") or {}
if page >= pagination.get("total_pages", page):
return hooks
page += 1
async function listHooks() {
let page = 1;
const hooks = [];
while (true) {
const body = await bc("GET", `/v3/hooks?page=${page}&limit=50`);
const rows = (body || {}).data || [];
if (rows.length === 0) return hooks;
for (const row of rows) {
hooks.push({
id: row.id,
scope: row.scope,
destination: row.destination,
is_active: Boolean(row.is_active),
});
}
const pagination = (body.meta || {}).pagination || {};
if (page >= (pagination.total_pages || page)) return hooks;
page++;
}
}
Decide, with one pure function
Keep the reconciliation in its own function that takes the desired manifest and the live hooks and returns three plain arrays: hooks to reactivate, entries to recreate, and hooks to leave alone. It never mutates the input, and it preserves the order of the desired list so tests stay deterministic. A pure function like this needs no network to test, which we do later.
def plan_webhook_reconciliation(desired, live):
"""Pure decision function. No network calls.
desired: list of {"scope": str, "destination": str, "is_active": bool, "headers": dict|None}
live: list of {"id": int, "scope": str, "destination": str, "is_active": bool}
returns: {"toReactivate": [id], "toRecreate": [desired entry], "toLeave": [id]}
"""
by_key = {f"{h['scope']}::{h['destination']}": h for h in live}
to_reactivate, to_recreate, to_leave = [], [], []
for entry in desired:
key = f"{entry['scope']}::{entry['destination']}"
match = by_key.get(key)
if match is None:
to_recreate.append(entry)
elif match["is_active"]:
to_leave.append(match["id"])
else:
to_reactivate.append(match["id"])
return {"toReactivate": to_reactivate, "toRecreate": to_recreate, "toLeave": to_leave}
/**
* Pure decision function. No network calls.
* desired: Array<{ scope, destination, is_active, headers? }>
* live: Array<{ id, scope, destination, is_active }>
* returns: { toReactivate: number[], toRecreate: desired[], toLeave: number[] }
*/
export function planWebhookReconciliation(desired, live) {
const byKey = new Map(live.map((h) => [`${h.scope}::${h.destination}`, h]));
const toReactivate = [];
const toRecreate = [];
const toLeave = [];
for (const entry of desired) {
const key = `${entry.scope}::${entry.destination}`;
const match = byKey.get(key);
if (!match) {
toRecreate.push(entry);
} else if (match.is_active) {
toLeave.push(match.id);
} else {
toReactivate.push(match.id);
}
}
return { toReactivate, toRecreate, toLeave };
}
Health check, then reactivate or recreate
For every id in toReactivate, send a lightweight GET to the destination first. Only call PUT /v3/hooks/{id} with {"is_active": true} if it answers 2xx, otherwise a still-broken destination just starts failing all over again. For every entry in toRecreate, call POST /v3/hooks to create it fresh, and remember the old id is gone for good, there is no undelete. Always keep the destination HTTPS only, since BigCommerce webhooks only support HTTPS on port 443.
def destination_is_healthy(destination):
try:
r = requests.get(destination, timeout=10)
return 200 <= r.status_code < 300
except requests.RequestException:
return False
def reactivate_hook(hook_id):
body = bc("PUT", f"/v3/hooks/{hook_id}", json={"is_active": True})
return (body or {}).get("data", {})
def recreate_hook(entry):
payload = {
"scope": entry["scope"],
"destination": entry["destination"],
"is_active": True,
"headers": entry.get("headers") or {},
}
body = bc("POST", "/v3/hooks", json=payload)
return (body or {}).get("data", {})
async function destinationIsHealthy(destination) {
try {
const res = await fetch(destination, { method: "GET" });
return res.ok;
} catch {
return false;
}
}
async function reactivateHook(hookId) {
const body = await bc("PUT", `/v3/hooks/${hookId}`, { is_active: true });
return (body || {}).data || {};
}
async function recreateHook(entry) {
const payload = {
scope: entry.scope,
destination: entry.destination,
is_active: true,
headers: entry.headers || {},
};
const body = await bc("POST", "/v3/hooks", payload);
return (body || {}).data || {};
}
Wire it together with a dry run guard, then re-verify
The loop lists the live hooks, runs the pure plan against your desired manifest, then walks each bucket. Leave DRY_RUN=true on the first runs so the script only logs what it would do. After any live PUT or POST, re-GET the hook to confirm is_active is now true and store the new id so your app's local registry stays in sync. Run it on a schedule, for example once an hour, since BigCommerce gives you no other signal that a hook went dark.
Always start with DRY_RUN=true. Never call PUT /v3/hooks/{id} with is_active: true without a fresh health check against the destination first, and never assume a hook missing from GET /v3/hooks can be restored, only recreated with a new id.
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 is_active or a fresh hook, never anything else.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely repair BigCommerce webhooks deactivated after failures.
BigCommerce retries a failed delivery, any response that is not HTTP 2xx,
with exponential backoff for up to 11 attempts spanning roughly 48 hours.
If the destination never returns a 2xx in that window, BigCommerce sets
is_active to false on that hook and emails the app's registered address,
permanently pausing delivery. A hook can also be auto deactivated after
90 days of zero triggered events. This lists every hook with GET /v3/hooks,
diffs it against a desired manifest of scope and destination pairs, health
checks the destination before reactivating, and recreates anything missing
entirely. Run on a schedule. Safe to run again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_webhooks")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DESIRED_MANIFEST = os.environ.get("DESIRED_WEBHOOKS_JSON", "[]")
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 plan_webhook_reconciliation(desired, live):
"""Pure decision function. No network calls.
desired: list of {"scope": str, "destination": str, "is_active": bool, "headers": dict|None}
live: list of {"id": int, "scope": str, "destination": str, "is_active": bool}
returns: {"toReactivate": [id], "toRecreate": [desired entry], "toLeave": [id]}
"""
by_key = {f"{h['scope']}::{h['destination']}": h for h in live}
to_reactivate, to_recreate, to_leave = [], [], []
for entry in desired:
key = f"{entry['scope']}::{entry['destination']}"
match = by_key.get(key)
if match is None:
to_recreate.append(entry)
elif match["is_active"]:
to_leave.append(match["id"])
else:
to_reactivate.append(match["id"])
return {"toReactivate": to_reactivate, "toRecreate": to_recreate, "toLeave": to_leave}
def list_hooks():
page = 1
hooks = []
while True:
body = bc("GET", f"/v3/hooks?page={page}&limit=50")
rows = (body or {}).get("data", [])
if not rows:
return hooks
for row in rows:
hooks.append({
"id": row["id"],
"scope": row["scope"],
"destination": row["destination"],
"is_active": bool(row["is_active"]),
})
pagination = (body.get("meta") or {}).get("pagination") or {}
if page >= pagination.get("total_pages", page):
return hooks
page += 1
def get_hook(hook_id):
body = bc("GET", f"/v3/hooks/{hook_id}")
return (body or {}).get("data", {})
def destination_is_healthy(destination):
if not destination.lower().startswith("https://"):
return False
try:
r = requests.get(destination, timeout=10)
return 200 <= r.status_code < 300
except requests.RequestException:
return False
def reactivate_hook(hook_id):
body = bc("PUT", f"/v3/hooks/{hook_id}", json={"is_active": True})
return (body or {}).get("data", {})
def recreate_hook(entry):
payload = {
"scope": entry["scope"],
"destination": entry["destination"],
"is_active": True,
"headers": entry.get("headers") or {},
}
body = bc("POST", "/v3/hooks", json=payload)
return (body or {}).get("data", {})
def run():
desired = json.loads(DESIRED_MANIFEST)
live = list_hooks()
plan = plan_webhook_reconciliation(desired, live)
reactivated, recreated, skipped = 0, 0, 0
for hook_id in plan["toReactivate"]:
hook = next((h for h in live if h["id"] == hook_id), None)
destination = hook["destination"] if hook else None
healthy = bool(destination) and destination_is_healthy(destination)
if not healthy:
log.warning("Hook %s destination not healthy yet, skipping reactivation.", hook_id)
skipped += 1
continue
log.info("Hook %s healthy. %s", hook_id, "would reactivate" if DRY_RUN else "reactivating")
if not DRY_RUN:
reactivate_hook(hook_id)
confirmed = get_hook(hook_id)
if not confirmed.get("is_active"):
raise RuntimeError(f"Hook {hook_id} did not confirm active after PUT")
reactivated += 1
for entry in plan["toRecreate"]:
if not entry["destination"].lower().startswith("https://"):
log.warning("Refusing to recreate non-HTTPS destination %s", entry["destination"])
skipped += 1
continue
log.info("Missing hook for %s %s. %s", entry["scope"], entry["destination"],
"would recreate" if DRY_RUN else "recreating")
if not DRY_RUN:
created = recreate_hook(entry)
new_id = created.get("id")
confirmed = get_hook(new_id) if new_id else {}
if not confirmed.get("is_active"):
raise RuntimeError(f"New hook for {entry['scope']} did not confirm active")
recreated += 1
log.info("Done. %d to reactivate, %d to recreate, %d skipped, %d already healthy.",
reactivated, recreated, skipped, len(plan["toLeave"]))
if __name__ == "__main__":
run()
/**
* Find and safely repair BigCommerce webhooks deactivated after failures.
*
* BigCommerce retries a failed delivery, any response that is not HTTP 2xx,
* with exponential backoff for up to 11 attempts spanning roughly 48 hours.
* If the destination never returns a 2xx in that window, BigCommerce sets
* is_active to false on that hook and emails the app's registered address,
* permanently pausing delivery. A hook can also be auto deactivated after
* 90 days of zero triggered events. This lists every hook with GET /v3/hooks,
* diffs it against a desired manifest of scope and destination pairs, health
* checks the destination before reactivating, and recreates anything missing
* entirely. Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/webhook-deactivated-after-failures/
*/
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 DESIRED_MANIFEST = process.env.DESIRED_WEBHOOKS_JSON || "[]";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* Pure decision function. No network calls.
* desired: Array<{ scope, destination, is_active, headers? }>
* live: Array<{ id, scope, destination, is_active }>
* returns: { toReactivate: number[], toRecreate: desired[], toLeave: number[] }
*/
export function planWebhookReconciliation(desired, live) {
const byKey = new Map(live.map((h) => [`${h.scope}::${h.destination}`, h]));
const toReactivate = [];
const toRecreate = [];
const toLeave = [];
for (const entry of desired) {
const key = `${entry.scope}::${entry.destination}`;
const match = byKey.get(key);
if (!match) {
toRecreate.push(entry);
} else if (match.is_active) {
toLeave.push(match.id);
} else {
toReactivate.push(match.id);
}
}
return { toReactivate, toRecreate, toLeave };
}
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 listHooks() {
let page = 1;
const hooks = [];
while (true) {
const body = await bc("GET", `/v3/hooks?page=${page}&limit=50`);
const rows = (body || {}).data || [];
if (rows.length === 0) return hooks;
for (const row of rows) {
hooks.push({
id: row.id,
scope: row.scope,
destination: row.destination,
is_active: Boolean(row.is_active),
});
}
const pagination = (body.meta || {}).pagination || {};
if (page >= (pagination.total_pages || page)) return hooks;
page++;
}
}
async function getHook(hookId) {
const body = await bc("GET", `/v3/hooks/${hookId}`);
return (body || {}).data || {};
}
async function destinationIsHealthy(destination) {
if (!destination.toLowerCase().startsWith("https://")) return false;
try {
const res = await fetch(destination, { method: "GET" });
return res.ok;
} catch {
return false;
}
}
async function reactivateHook(hookId) {
const body = await bc("PUT", `/v3/hooks/${hookId}`, { is_active: true });
return (body || {}).data || {};
}
async function recreateHook(entry) {
const payload = {
scope: entry.scope,
destination: entry.destination,
is_active: true,
headers: entry.headers || {},
};
const body = await bc("POST", "/v3/hooks", payload);
return (body || {}).data || {};
}
export async function run() {
const desired = JSON.parse(DESIRED_MANIFEST);
const live = await listHooks();
const plan = planWebhookReconciliation(desired, live);
let reactivated = 0;
let recreated = 0;
let skipped = 0;
for (const hookId of plan.toReactivate) {
const hook = live.find((h) => h.id === hookId);
const destination = hook ? hook.destination : null;
const healthy = Boolean(destination) && (await destinationIsHealthy(destination));
if (!healthy) {
console.warn(`Hook ${hookId} destination not healthy yet, skipping reactivation.`);
skipped++;
continue;
}
console.log(`Hook ${hookId} healthy. ${DRY_RUN ? "would reactivate" : "reactivating"}`);
if (!DRY_RUN) {
await reactivateHook(hookId);
const confirmed = await getHook(hookId);
if (!confirmed.is_active) throw new Error(`Hook ${hookId} did not confirm active after PUT`);
}
reactivated++;
}
for (const entry of plan.toRecreate) {
if (!entry.destination.toLowerCase().startsWith("https://")) {
console.warn(`Refusing to recreate non-HTTPS destination ${entry.destination}`);
skipped++;
continue;
}
console.log(`Missing hook for ${entry.scope} ${entry.destination}. ${DRY_RUN ? "would recreate" : "recreating"}`);
if (!DRY_RUN) {
const created = await recreateHook(entry);
const confirmed = created.id ? await getHook(created.id) : {};
if (!confirmed.is_active) throw new Error(`New hook for ${entry.scope} did not confirm active`);
}
recreated++;
}
console.log(`Done. ${reactivated} to reactivate, ${recreated} to recreate, ${skipped} skipped, ${plan.toLeave.length} already healthy.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The reconciliation rule is the part most worth testing, because it decides which hooks get reactivated, which get recreated, and which are left alone. Because we kept plan_webhook_reconciliation pure, no I/O at all, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
from reconcile_webhooks import plan_webhook_reconciliation
def desired_entry(scope="store/order/created", destination="https://app.example.com/hooks", **over):
base = {"scope": scope, "destination": destination, "is_active": True, "headers": {}}
base.update(over)
return base
def live_hook(hook_id, scope="store/order/created", destination="https://app.example.com/hooks", is_active=True):
return {"id": hook_id, "scope": scope, "destination": destination, "is_active": is_active}
def test_healthy_hook_is_left_alone():
plan = plan_webhook_reconciliation([desired_entry()], [live_hook(1)])
assert plan == {"toReactivate": [], "toRecreate": [], "toLeave": [1]}
def test_inactive_hook_goes_to_reactivate():
plan = plan_webhook_reconciliation([desired_entry()], [live_hook(1, is_active=False)])
assert plan["toReactivate"] == [1]
assert plan["toRecreate"] == []
assert plan["toLeave"] == []
def test_missing_hook_goes_to_recreate():
plan = plan_webhook_reconciliation([desired_entry()], [])
assert plan["toRecreate"] == [desired_entry()]
assert plan["toReactivate"] == []
assert plan["toLeave"] == []
def test_mixed_manifest_preserves_desired_order():
desired = [
desired_entry(scope="store/order/created"),
desired_entry(scope="store/product/updated", destination="https://app.example.com/hooks"),
desired_entry(scope="store/cart/abandoned", destination="https://app.example.com/hooks"),
]
live = [
live_hook(1, scope="store/order/created", is_active=True),
live_hook(2, scope="store/product/updated", is_active=False),
]
plan = plan_webhook_reconciliation(desired, live)
assert plan["toLeave"] == [1]
assert plan["toReactivate"] == [2]
assert plan["toRecreate"] == [desired_entry(scope="store/cart/abandoned", destination="https://app.example.com/hooks")]
def test_does_not_mutate_inputs():
desired = [desired_entry()]
live = [live_hook(1, is_active=False)]
desired_copy = [dict(d) for d in desired]
live_copy = [dict(h) for h in live]
plan_webhook_reconciliation(desired, live)
assert desired == desired_copy
assert live == live_copy
def test_different_destination_same_scope_counts_as_missing():
plan = plan_webhook_reconciliation(
[desired_entry(destination="https://app.example.com/new-hooks")],
[live_hook(1, destination="https://app.example.com/old-hooks")],
)
assert plan["toRecreate"] == [desired_entry(destination="https://app.example.com/new-hooks")]
assert plan["toReactivate"] == []
assert plan["toLeave"] == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { planWebhookReconciliation } from "./reconcile-webhooks.js";
const desiredEntry = (over = {}) => ({
scope: "store/order/created",
destination: "https://app.example.com/hooks",
is_active: true,
headers: {},
...over,
});
const liveHook = (id, over = {}) => ({
id,
scope: "store/order/created",
destination: "https://app.example.com/hooks",
is_active: true,
...over,
});
test("healthy hook is left alone", () => {
const plan = planWebhookReconciliation([desiredEntry()], [liveHook(1)]);
assert.deepEqual(plan, { toReactivate: [], toRecreate: [], toLeave: [1] });
});
test("inactive hook goes to reactivate", () => {
const plan = planWebhookReconciliation([desiredEntry()], [liveHook(1, { is_active: false })]);
assert.deepEqual(plan.toReactivate, [1]);
assert.deepEqual(plan.toRecreate, []);
assert.deepEqual(plan.toLeave, []);
});
test("missing hook goes to recreate", () => {
const plan = planWebhookReconciliation([desiredEntry()], []);
assert.deepEqual(plan.toRecreate, [desiredEntry()]);
assert.deepEqual(plan.toReactivate, []);
assert.deepEqual(plan.toLeave, []);
});
test("mixed manifest preserves desired order", () => {
const desired = [
desiredEntry({ scope: "store/order/created" }),
desiredEntry({ scope: "store/product/updated" }),
desiredEntry({ scope: "store/cart/abandoned" }),
];
const live = [
liveHook(1, { scope: "store/order/created", is_active: true }),
liveHook(2, { scope: "store/product/updated", is_active: false }),
];
const plan = planWebhookReconciliation(desired, live);
assert.deepEqual(plan.toLeave, [1]);
assert.deepEqual(plan.toReactivate, [2]);
assert.deepEqual(plan.toRecreate, [desiredEntry({ scope: "store/cart/abandoned" })]);
});
test("does not mutate inputs", () => {
const desired = [desiredEntry()];
const live = [liveHook(1, { is_active: false })];
const desiredCopy = JSON.parse(JSON.stringify(desired));
const liveCopy = JSON.parse(JSON.stringify(live));
planWebhookReconciliation(desired, live);
assert.deepEqual(desired, desiredCopy);
assert.deepEqual(live, liveCopy);
});
test("different destination same scope counts as missing", () => {
const plan = planWebhookReconciliation(
[desiredEntry({ destination: "https://app.example.com/new-hooks" })],
[liveHook(1, { destination: "https://app.example.com/old-hooks" })],
);
assert.deepEqual(plan.toRecreate, [desiredEntry({ destination: "https://app.example.com/new-hooks" })]);
assert.deepEqual(plan.toReactivate, []);
assert.deepEqual(plan.toLeave, []);
});
Case studies
The fulfillment app that missed a whole weekend
A fulfillment integration's endpoint went down during a Friday afternoon deploy and stayed broken through the weekend because the on-call rotation was thin. BigCommerce retried every order event for about 48 hours, never got a 2xx, and quietly set the hook's is_active to false. The deactivation email went to a shared inbox nobody checked until Monday.
By the time someone noticed orders were not flowing into the warehouse system, three days of order events were gone for good, since BigCommerce does not replay history. Running the reconciliation job hourly now means a dead hook gets caught, health checked, and turned back on within the hour instead of over a long weekend.
The hook that vanished during an app migration
A merchant migrating between two versions of an app had an old install's hooks cleaned up as part of the process, but one scope, store/product/updated, was never recreated on the new install. There was no error, the app just stopped hearing about price and inventory changes, and a sync job downstream slowly drifted out of date with the storefront.
Diffing GET /v3/hooks against the app's expected manifest caught the gap immediately, since the scope and destination pair was completely absent from the live response. The script recreated it with POST /v3/hooks, and the new id was written back into the app's own registry so nothing had to be found by hand again.
After this runs on a schedule, a webhook going quiet is caught within one reconciliation cycle instead of when a downstream system is found stale days later. Every reactivation is preceded by a real health check against the destination, so the fix never restarts a flood of failures against something still broken. Every recreated hook gets its new id written back into your own registry, so BigCommerce's habit of never truly undeleting a hook stops being a surprise.
FAQ
Why did my BigCommerce webhook stop firing?
BigCommerce retries a failed delivery, any response that is not HTTP 2xx, including timeouts and TLS errors, with exponential backoff for up to 11 attempts spanning roughly 48 hours. If your destination never returns a 2xx in that window, BigCommerce sets is_active to false on that hook and emails the address registered for the app. A webhook can also be auto deactivated after 90 days with zero triggered events.
How do I know if a BigCommerce webhook was deactivated?
BigCommerce does not expose a delivery attempt or failure log through the API, and nothing surfaces in the storefront or admin. You have to explicitly poll GET /v3/hooks and check is_active on every hook, or diff the live set against the scope and destination pairs your app is supposed to own to catch one that went missing entirely.
Is it safe to just flip is_active back to true?
Not blindly. If the destination is still broken, reactivating restarts a flood of failing callbacks against it. Send a health check to the destination first and only call PUT /v3/hooks/{id} with is_active true once it responds 2xx. If the hook is missing entirely you cannot undelete it, you have to POST a new one, which gets a new id.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Webhooks Overview. developer.bigcommerce.com/docs/integrations/webhooks
- BigCommerce Docs: Webhooks Overview, retry and deactivation behavior. docs.bigcommerce.com/developer/docs/integrations/webhooks/overview
- BigCommerce Community: How to avoid webhook deactivation. support.bigcommerce.com webhook deactivation question
On the solution:
- BigCommerce Developer Center: Webhooks v3 API Reference. developer.bigcommerce.com/docs/rest-management/webhooks
- BigCommerce Developer Center: Webhooks Tutorial, Store Management API. developer.bigcommerce.com/api-docs/store-management/webhooks/tutorial
- BigCommerce Docs: Webhooks v3 REST API Reference. docs.bigcommerce.com/developer/api-reference/rest/integrations/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 catch a dead webhook?
If this saved you a stale ERP sync or a silent order gap, 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