Diagnostic Webhooks

Order webhooks stop firing entirely with no surfaced error

Orders keep coming in, the storefront looks fine, and then you notice your fulfillment queue or inventory sync has not heard from BigCommerce in hours. Nothing in the control panel says a webhook died. BigCommerce quietly disables a subscription after it keeps failing to deliver, and separately can blocklist your whole receiving domain for a few minutes at a time if its short-term success rate dips, and neither event shows up as an alert anywhere you would normally look. Here is why that happens and a small script that finds the gap and flags it instead of guessing.

Python and Node.js BigCommerce V3 Hooks API Safe by default (dry run)
Close-up of server cooling fans in a vibrant data center.
Photo by Winston Chen on Unsplash
The short answer

BigCommerce retries a failing webhook destination on a backoff schedule for roughly 48 hours, and if it still is not returning HTTP 200, it permanently flips that subscription's is_active to false, emailing only the address on file for the subscribing app. Separately, once a destination domain has received 100 or more requests, BigCommerce tracks a rolling 2 minute success and failure ratio and will blocklist that whole domain for 3 minutes if the success rate drops below 90 percent, which looks like everything stopped even though the hook still shows is_active:true. Neither mechanism raises a dashboard alert. Pull recent orders with GET /v2/orders, pull your subscriptions with GET /v3/hooks, and diff both against your own webhook receiver log to catch the gap. Full detection code, tests, and a guarded (dry run) repair path are below.

The problem in plain words

A BigCommerce webhook subscription is a promise, not a guarantee. When your endpoint stops answering with a 200, either because it is down, redeployed with a broken route, behind an expired certificate, or rejecting the payload for an auth reason, BigCommerce does not just give up on the next event. It retries the delivery on a backoff schedule for roughly 48 hours. If your endpoint still has not recovered by the end of that window, BigCommerce permanently disables the subscription by setting is_active to false.

The only notice of that deactivation is an email, sent to whatever address is on file for the app that created the subscription, not to the store owner, not to the control panel, and not to any webhook of your own. If that inbox is unmonitored, or the subscribing app is one of several integrations on the store, the disabling can go unnoticed for days. Meanwhile orders, customers, and everything else in the store keep being created completely normally. Nothing on the storefront or in the order list looks wrong.

There is a second, separate failure mode that is even harder to see. BigCommerce tracks delivery success against each destination domain in a rolling 2 minute window, and once it has sent 100 or more requests to that domain, it will blocklist the entire domain for 3 minutes if the success rate in that window drops below 90 percent. During that 3 minute window, deliveries to every hook pointed at that domain fail, even ones with a completely healthy is_active:true record, which reads identically to a wider outage from the receiving side.

Order event store/order/* Retries ~48h destination not 200 Window expires is_active: false email only, no dashboard alert Events stop cold Orders keep being created normally the whole time
Retries run quietly for about two days, then the hook is permanently disabled with only an email to the subscribing app's address. Nothing shows in the store control panel, and orders keep flowing the entire time.

Why it happens

BigCommerce is protecting itself and its delivery infrastructure from endpoints that cannot keep up, but the way it does that leaves store owners with no visible signal. The common paths into this:

In every one of these cases the store's control panel looks completely normal, because there is no webhook-of-webhooks and no dashboard banner for either mechanism. The only ground truth is comparing what orders actually happened against what your own systems actually received. See the citations at the end for the exact support threads and docs.

The key insight

is_active:true on a hook record is not proof that hook is delivering. It only proves BigCommerce has not yet permanently disabled it. The reliable check is to compare three things: the timestamped orders that actually happened, the webhook deliveries your own receiver actually logged, and each hook's current is_active and updated_at. A hook with is_active:false is a clear deactivation. A hook that still reads is_active:true but has a delivery gap larger than your normal latency is the quieter, domain-blocklisting kind of failure, and both need a human, not an automatic flip back to on.

The fix, as a flow

We do not touch the live checkout or order flow. We add a job that pulls recent orders, pulls the store's current hook subscriptions, diffs both against the store's own webhook receiver log, and turns any mismatch into a named finding instead of a silent gap.

Scheduled job runs on a timer GET /v2/orders recent order timestamps GET /v3/hooks is_active, scope, updated_at Diff vs receiver log detect_webhook_gap() Gap or inactive? no, nothing to report yes Report finding hook id, scope, reason
The job only reports a finding, naming the hook id, scope, and destination. A guarded reactivation is a separate, deliberate step taken only after the destination is confirmed healthy.

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) and Webhooks scope so it can list orders and hooks and, later, perform a guarded reactivation. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export LOOKBACK_DAYS="1"
export STALE_AFTER_MINUTES="30"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

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

Talk to the V2 orders and V3 hooks REST APIs

Orders live under https://api.bigcommerce.com/stores/{store_hash}/v2/, hooks live under the V3 base and wrap results in {data, meta.pagination}. The same token in the X-Auth-Token header works for both. A small helper handles GET and PUT 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_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

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

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

def bc_put(base, path, body):
    r = requests.put(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

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

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

async function bcPut(base, path, body) {
  const res = await fetch(`${base}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

Pull the three inputs the decision needs

Call GET /v2/orders?min_date_created={ISO date}&sort=date_created:desc for a timestamped ground-truth list of order events. Call GET /v3/hooks (optionally ?is_active=true) for every subscription's id, scope, destination, is_active, and updated_at. And read your own webhook receiver log table for the timestamps you actually got, grouped by scope.

step3.py
def recent_orders(lookback_days):
    orders = []
    page = 1
    while True:
        batch = bc_get(API_BASE_V2, "/orders", {
            "min_date_created": f"-{lookback_days} days",
            "sort": "date_created:desc",
            "page": page,
            "limit": 50,
        })
        if not batch:
            return orders
        orders.extend(batch)
        page += 1

def current_hooks():
    result = bc_get(API_BASE_V3, "/hooks", {"limit": 250})
    return result.get("data", []) if isinstance(result, dict) else result
step3.js
async function recentOrders(lookbackDays) {
  const orders = [];
  let page = 1;
  while (true) {
    const batch = await bcGet(API_BASE_V2, "/orders", {
      min_date_created: `-${lookbackDays} days`,
      sort: "date_created:desc",
      page,
      limit: 50,
    });
    if (!batch.length) return orders;
    orders.push(...batch);
    page += 1;
  }
}

async function currentHooks() {
  const result = await bcGet(API_BASE_V3, "/hooks", { limit: 250 });
  return Array.isArray(result) ? result : result.data || [];
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order timestamps, your webhook log timestamps grouped by scope, the raw hook records, and the current time, and returns a list of findings. Any hook matching store/order/* or store/customer/* with is_active:false is a deactivation. Any hook still is_active:true whose scope's last received delivery is older than the newest matching order event, by more than the stale threshold, is a silent delivery gap.

detect.py
from datetime import datetime, timedelta, timezone

def _parse(ts):
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))

def _scope_matches(scope):
    return scope.startswith("store/order/") or scope.startswith("store/customer/")

def detect_webhook_gap(order_timestamps, webhook_log_timestamps, hook_records, now, stale_after_minutes=30):
    findings = []
    now_dt = _parse(now)
    latest_order = max((_parse(t) for t in order_timestamps), default=None)

    for hook in hook_records:
        scope = hook.get("scope", "")
        if not _scope_matches(scope):
            continue

        if not hook.get("is_active", True):
            findings.append({
                "hook_id": hook.get("id"), "scope": scope,
                "destination": hook.get("destination"), "is_active": False,
                "reason": "deactivated",
            })
            continue

        if latest_order is None:
            continue

        log_times = webhook_log_timestamps.get(scope, [])
        last_received = max((_parse(t) for t in log_times), default=None)

        gap_reference = last_received or latest_order
        stale_cutoff = now_dt - timedelta(minutes=stale_after_minutes)

        if latest_order > gap_reference and gap_reference < stale_cutoff:
            findings.append({
                "hook_id": hook.get("id"), "scope": scope,
                "destination": hook.get("destination"), "is_active": True,
                "reason": "stale_no_recent_delivery",
            })

    return findings
detect.js
function scopeMatches(scope) {
  return scope.startsWith("store/order/") || scope.startsWith("store/customer/");
}

export function detectWebhookGap(orderTimestamps, webhookLogTimestamps, hookRecords, now, staleAfterMinutes = 30) {
  const findings = [];
  const nowMs = new Date(now).getTime();
  const orderMs = orderTimestamps.map((t) => new Date(t).getTime());
  const latestOrder = orderMs.length ? Math.max(...orderMs) : null;

  for (const hook of hookRecords) {
    const scope = hook.scope || "";
    if (!scopeMatches(scope)) continue;

    if (hook.is_active === false) {
      findings.push({ hook_id: hook.id, scope, destination: hook.destination, is_active: false, reason: "deactivated" });
      continue;
    }

    if (latestOrder === null) continue;

    const logTimes = (webhookLogTimestamps[scope] || []).map((t) => new Date(t).getTime());
    const lastReceived = logTimes.length ? Math.max(...logTimes) : null;

    const gapReference = lastReceived ?? latestOrder;
    const staleCutoff = nowMs - staleAfterMinutes * 60 * 1000;

    if (latestOrder > gapReference && gapReference < staleCutoff) {
      findings.push({ hook_id: hook.id, scope, destination: hook.destination, is_active: true, reason: "stale_no_recent_delivery" });
    }
  }

  return findings;
}
5

Report the finding, do not auto-fix it

Every finding names the hook id, scope, destination, and reason. That is the whole output on a normal run. Reactivating is deliberately a separate, manual-gated step, because the receiver's underlying problem is still there until someone confirms otherwise.

report.py
def report_finding(finding):
    log.warning(
        "webhook gap: hook_id=%s scope=%s destination=%s is_active=%s reason=%s",
        finding["hook_id"], finding["scope"], finding["destination"],
        finding["is_active"], finding["reason"],
    )
report.js
function reportFinding(finding) {
  console.warn(
    `webhook gap: hook_id=${finding.hook_id} scope=${finding.scope} destination=${finding.destination} is_active=${finding.is_active} reason=${finding.reason}`
  );
}
6

Wire it together with a dry run guard on reactivation

The loop pulls all three inputs, runs the pure function, and reports every finding. Only after a manual HEAD or GET check confirms the destination now returns a healthy response should reactivation run, and even then it goes through PUT /v3/hooks/{hook_id} with {"is_active": true} behind DRY_RUN, which logs the intended call and skips it unless DRY_RUN=false. If a hook has been disabled long enough to risk the 90 day pruning window, recreate it fresh with POST /v3/hooks instead of trusting the old id.

Run it safe

Never flip is_active back to true automatically. Confirm the destination is actually healthy first, keep DRY_RUN=true until you have reviewed the findings, and treat a hook disabled for a long stretch as a candidate for a fresh POST /v3/hooks rather than a reactivated old one.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pulls the three inputs, runs the pure gap detector, logs every finding, and only attempts a guarded, dry-run-respecting reactivation when explicitly told to.

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

detect_webhook_gap.py
"""Detect BigCommerce order webhooks that stopped firing with no surfaced error.

BigCommerce retries a failing webhook destination on a backoff schedule for
roughly 48 hours, then permanently sets is_active to false on that subscription,
emailing only the address on file for the subscribing app. Separately, once a
destination domain has received 100 or more requests, BigCommerce tracks a
rolling 2 minute success and failure ratio and blocklists the whole domain for
3 minutes if the success rate drops below 90 percent, which can fail deliveries
even on a hook that still reads is_active:true. Neither mechanism raises a
dashboard alert. This job pulls recent orders, the store's current hook
subscriptions, and the store's own webhook receiver log, and reports any hook
that is deactivated or has gone stale with no recent delivery. It never
auto-reactivates; repair is a separate, guarded, dry-run-respecting step.

Guide: https://www.allanninal.dev/bigcommerce/order-webhooks-stop-firing-silently/
"""
import os
import logging
from datetime import datetime, timedelta

import requests

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

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

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


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


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


def _parse(ts):
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))


def _scope_matches(scope):
    return scope.startswith("store/order/") or scope.startswith("store/customer/")


def detect_webhook_gap(order_timestamps, webhook_log_timestamps, hook_records, now, stale_after_minutes=30):
    """Pure decision. No network, no side effects.

    order_timestamps: ISO8601 date_created/date_modified values from GET /v2/orders.
    webhook_log_timestamps: {scope: [received_at ISO8601, ...]} from the store's
        own webhook receiver log.
    hook_records: raw items from GET /v3/hooks, each with id, scope, destination,
        is_active, updated_at.
    now: ISO8601 current time, injected for testability.
    stale_after_minutes: threshold beyond normal delivery latency before a
        still-active hook counts as stale.

    Returns a list of finding dicts: {hook_id, scope, destination, is_active, reason}
    with reason in {"deactivated", "stale_no_recent_delivery"}.
    """
    findings = []
    now_dt = _parse(now)
    latest_order = max((_parse(t) for t in order_timestamps), default=None)

    for hook in hook_records:
        scope = hook.get("scope", "")
        if not _scope_matches(scope):
            continue

        if not hook.get("is_active", True):
            findings.append({
                "hook_id": hook.get("id"),
                "scope": scope,
                "destination": hook.get("destination"),
                "is_active": False,
                "reason": "deactivated",
            })
            continue

        if latest_order is None:
            continue

        log_times = webhook_log_timestamps.get(scope, [])
        last_received = max((_parse(t) for t in log_times), default=None)

        gap_reference = last_received or latest_order
        stale_cutoff = now_dt - timedelta(minutes=stale_after_minutes)

        if latest_order > gap_reference and gap_reference < stale_cutoff:
            findings.append({
                "hook_id": hook.get("id"),
                "scope": scope,
                "destination": hook.get("destination"),
                "is_active": True,
                "reason": "stale_no_recent_delivery",
            })

    return findings


def recent_orders(lookback_days):
    """Page through recent orders as a timestamped ground-truth event list."""
    orders = []
    page = 1
    while True:
        batch = bc_get(API_BASE_V2, "/orders", {
            "min_date_created": f"-{lookback_days} days",
            "sort": "date_created:desc",
            "page": page,
            "limit": 50,
        })
        if not batch:
            return orders
        orders.extend(batch)
        page += 1


def current_hooks():
    result = bc_get(API_BASE_V3, "/hooks", {"limit": 250})
    return result.get("data", []) if isinstance(result, dict) else result


def load_webhook_log_timestamps():
    """Read your own webhook receiver log table, grouped by scope.

    Replace this with a real query against your receiver's storage. Left as a
    stub here since the log table is store-specific infrastructure.
    """
    return {}


def reactivate_hook(hook_id):
    """Guarded reactivation. Only call this after confirming the destination
    is healthy again. Always wrapped in DRY_RUN.
    """
    if DRY_RUN:
        log.info("DRY_RUN: would PUT /v3/hooks/%s {'is_active': True}", hook_id)
        return None
    return bc_put(API_BASE_V3, f"/hooks/{hook_id}", {"is_active": True})


def run():
    orders = recent_orders(LOOKBACK_DAYS)
    order_timestamps = [o.get("date_modified") or o.get("date_created") for o in orders if o.get("date_created")]
    hooks = current_hooks()
    webhook_log_timestamps = load_webhook_log_timestamps()
    now = datetime.utcnow().isoformat() + "Z"

    findings = detect_webhook_gap(order_timestamps, webhook_log_timestamps, hooks, now, STALE_AFTER_MINUTES)

    for finding in findings:
        log.warning(
            "webhook gap: hook_id=%s scope=%s destination=%s is_active=%s reason=%s",
            finding["hook_id"], finding["scope"], finding["destination"],
            finding["is_active"], finding["reason"],
        )

    log.info("Done. %d order(s) checked, %d hook finding(s).", len(orders), len(findings))
    return findings


if __name__ == "__main__":
    run()
detect-webhook-gap.js
/**
 * Detect BigCommerce order webhooks that stopped firing with no surfaced error.
 *
 * BigCommerce retries a failing webhook destination on a backoff schedule for
 * roughly 48 hours, then permanently sets is_active to false on that
 * subscription, emailing only the address on file for the subscribing app.
 * Separately, once a destination domain has received 100 or more requests,
 * BigCommerce tracks a rolling 2 minute success and failure ratio and
 * blocklists the whole domain for 3 minutes if the success rate drops below
 * 90 percent, which can fail deliveries even on a hook that still reads
 * is_active:true. Neither mechanism raises a dashboard alert. This job pulls
 * recent orders, the store's current hook subscriptions, and the store's own
 * webhook receiver log, and reports any hook that is deactivated or has gone
 * stale with no recent delivery. It never auto-reactivates; repair is a
 * separate, guarded, dry-run-respecting step.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/order-webhooks-stop-firing-silently/
 */
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_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 1);
const STALE_AFTER_MINUTES = Number(process.env.STALE_AFTER_MINUTES || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

function scopeMatches(scope) {
  return scope.startsWith("store/order/") || scope.startsWith("store/customer/");
}

/**
 * Pure decision. No network, no side effects.
 *
 * orderTimestamps: ISO8601 date_created/date_modified values from GET /v2/orders.
 * webhookLogTimestamps: {scope: [receivedAt ISO8601, ...]} from the store's own
 *   webhook receiver log.
 * hookRecords: raw items from GET /v3/hooks, each with id, scope, destination,
 *   is_active, updated_at.
 * now: ISO8601 current time, injected for testability.
 * staleAfterMinutes: threshold beyond normal delivery latency before a
 *   still-active hook counts as stale.
 *
 * Returns a list of finding objects: {hook_id, scope, destination, is_active, reason}
 * with reason in "deactivated" or "stale_no_recent_delivery".
 */
export function detectWebhookGap(orderTimestamps, webhookLogTimestamps, hookRecords, now, staleAfterMinutes = 30) {
  const findings = [];
  const nowMs = new Date(now).getTime();
  const orderMs = orderTimestamps.map((t) => new Date(t).getTime()).filter((t) => Number.isFinite(t));
  const latestOrder = orderMs.length ? Math.max(...orderMs) : null;

  for (const hook of hookRecords) {
    const scope = hook.scope || "";
    if (!scopeMatches(scope)) continue;

    if (hook.is_active === false) {
      findings.push({
        hook_id: hook.id,
        scope,
        destination: hook.destination,
        is_active: false,
        reason: "deactivated",
      });
      continue;
    }

    if (latestOrder === null) continue;

    const logTimes = (webhookLogTimestamps[scope] || [])
      .map((t) => new Date(t).getTime())
      .filter((t) => Number.isFinite(t));
    const lastReceived = logTimes.length ? Math.max(...logTimes) : null;

    const gapReference = lastReceived ?? latestOrder;
    const staleCutoff = nowMs - staleAfterMinutes * 60 * 1000;

    if (latestOrder > gapReference && gapReference < staleCutoff) {
      findings.push({
        hook_id: hook.id,
        scope,
        destination: hook.destination,
        is_active: true,
        reason: "stale_no_recent_delivery",
      });
    }
  }

  return findings;
}

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

async function bcPut(base, path, body) {
  const res = await fetch(`${base}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function recentOrders(lookbackDays) {
  const orders = [];
  let page = 1;
  while (true) {
    const batch = await bcGet(API_BASE_V2, "/orders", {
      min_date_created: `-${lookbackDays} days`,
      sort: "date_created:desc",
      page,
      limit: 50,
    });
    if (!batch.length) return orders;
    orders.push(...batch);
    page += 1;
  }
}

async function currentHooks() {
  const result = await bcGet(API_BASE_V3, "/hooks", { limit: 250 });
  return Array.isArray(result) ? result : result.data || [];
}

function loadWebhookLogTimestamps() {
  // Replace this with a real query against your receiver's storage. Left as
  // a stub here since the log table is store-specific infrastructure.
  return {};
}

async function reactivateHook(hookId) {
  if (DRY_RUN) {
    console.log(`DRY_RUN: would PUT /v3/hooks/${hookId} {"is_active": true}`);
    return null;
  }
  return bcPut(API_BASE_V3, `/hooks/${hookId}`, { is_active: true });
}

export async function run() {
  const orders = await recentOrders(LOOKBACK_DAYS);
  const orderTimestamps = orders.map((o) => o.date_modified || o.date_created).filter(Boolean);
  const hooks = await currentHooks();
  const webhookLogTimestamps = loadWebhookLogTimestamps();
  const now = new Date().toISOString();

  const findings = detectWebhookGap(orderTimestamps, webhookLogTimestamps, hooks, now, STALE_AFTER_MINUTES);

  for (const finding of findings) {
    console.warn(
      `webhook gap: hook_id=${finding.hook_id} scope=${finding.scope} destination=${finding.destination} is_active=${finding.is_active} reason=${finding.reason}`
    );
  }

  console.log(`Done. ${orders.length} order(s) checked, ${findings.length} hook finding(s).`);
  return findings;
}

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

Add a test

The gap detector is the part most worth testing, because it decides whether a real hook gets reported as broken. Because detect_webhook_gap takes only plain values, including an injected now, and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the findings.

test_order_webhook_gap.py
from detect_webhook_gap import detect_webhook_gap

NOW = "2026-07-10T12:00:00Z"


def hook(id_=1, scope="store/order/statusUpdated", is_active=True, destination="https://example.com/hooks"):
    return {"id": id_, "scope": scope, "destination": destination, "is_active": is_active, "updated_at": NOW}


def test_no_findings_when_everything_is_current():
    orders = ["2026-07-10T11:55:00Z"]
    log = {"store/order/statusUpdated": ["2026-07-10T11:56:00Z"]}
    assert detect_webhook_gap(orders, log, [hook()], NOW) == []


def test_flags_deactivated_hook_regardless_of_delivery_log():
    orders = ["2026-07-10T11:55:00Z"]
    log = {"store/order/statusUpdated": ["2026-07-10T11:56:00Z"]}
    findings = detect_webhook_gap(orders, log, [hook(is_active=False)], NOW)
    assert len(findings) == 1
    assert findings[0]["reason"] == "deactivated"
    assert findings[0]["is_active"] is False


def test_flags_stale_active_hook_with_no_recent_delivery():
    orders = ["2026-07-10T11:55:00Z"]
    log = {"store/order/statusUpdated": ["2026-07-10T10:00:00Z"]}
    findings = detect_webhook_gap(orders, log, [hook()], NOW, stale_after_minutes=30)
    assert len(findings) == 1
    assert findings[0]["reason"] == "stale_no_recent_delivery"


def test_no_finding_when_gap_is_within_stale_threshold():
    orders = ["2026-07-10T11:55:00Z"]
    log = {"store/order/statusUpdated": ["2026-07-10T11:45:00Z"]}
    assert detect_webhook_gap(orders, log, [hook()], NOW, stale_after_minutes=30) == []


def test_ignores_hooks_outside_order_and_customer_scope():
    orders = ["2026-07-10T11:55:00Z"]
    log = {}
    findings = detect_webhook_gap(orders, log, [hook(scope="store/product/updated", is_active=False)], NOW)
    assert findings == []


def test_no_findings_when_there_are_no_orders_at_all():
    assert detect_webhook_gap([], {}, [hook()], NOW) == []
detect-webhook-gap.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectWebhookGap } from "./detect-webhook-gap.js";

const NOW = "2026-07-10T12:00:00Z";

const hook = ({ id = 1, scope = "store/order/statusUpdated", isActive = true, destination = "https://example.com/hooks" } = {}) => ({
  id, scope, destination, is_active: isActive, updated_at: NOW,
});

test("no findings when everything is current", () => {
  const orders = ["2026-07-10T11:55:00Z"];
  const log = { "store/order/statusUpdated": ["2026-07-10T11:56:00Z"] };
  assert.deepEqual(detectWebhookGap(orders, log, [hook()], NOW), []);
});

test("flags deactivated hook regardless of delivery log", () => {
  const orders = ["2026-07-10T11:55:00Z"];
  const log = { "store/order/statusUpdated": ["2026-07-10T11:56:00Z"] };
  const findings = detectWebhookGap(orders, log, [hook({ isActive: false })], NOW);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].reason, "deactivated");
  assert.equal(findings[0].is_active, false);
});

test("flags stale active hook with no recent delivery", () => {
  const orders = ["2026-07-10T11:55:00Z"];
  const log = { "store/order/statusUpdated": ["2026-07-10T10:00:00Z"] };
  const findings = detectWebhookGap(orders, log, [hook()], NOW, 30);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].reason, "stale_no_recent_delivery");
});

test("no finding when gap is within stale threshold", () => {
  const orders = ["2026-07-10T11:55:00Z"];
  const log = { "store/order/statusUpdated": ["2026-07-10T11:45:00Z"] };
  assert.deepEqual(detectWebhookGap(orders, log, [hook()], NOW, 30), []);
});

test("ignores hooks outside order and customer scope", () => {
  const orders = ["2026-07-10T11:55:00Z"];
  const findings = detectWebhookGap(orders, {}, [hook({ scope: "store/product/updated", isActive: false })], NOW);
  assert.deepEqual(findings, []);
});

test("no findings when there are no orders at all", () => {
  assert.deepEqual(detectWebhookGap([], {}, [hook()], NOW), []);
});

Case studies

Redeployed receiver

The integration that broke its own auth check on deploy

A fulfillment integration redeployed its webhook receiver with a stricter signature check, and a header mismatch meant every delivery started failing. Nobody noticed for two days, since orders kept flowing and the store's admin showed nothing unusual. Then the deactivation email landed in an inbox nobody read regularly, and by the time someone found it, the fulfillment queue was a full day behind.

Now a scheduled check compares order timestamps against the webhook receiver log every 15 minutes. The first run after the auth check broke immediately reported a stale_no_recent_delivery finding, hours before the 48 hour deactivation would have even kicked in.

Domain blocklisted mid-deploy

The brief outage that looked like a bigger one

During a routine deploy, a receiver returned 5xx for about ninety seconds while new pods came up. That was enough to drop the domain's rolling success rate below 90 percent, and BigCommerce blocklisted the whole domain for 3 minutes. Every hook pointed at that domain, still showing is_active true, failed to deliver during the window, and the on-call engineer briefly assumed a full outage.

The gap detector flagged the affected scopes as stale_no_recent_delivery with is_active still true, which matched the domain blocklisting pattern rather than a deactivation, so the team knew immediately it was a transient issue and not a broken subscription needing repair.

What good looks like

After this runs on a schedule, a deactivated hook or a stale, silently-blocked one is a named finding within minutes, not a mystery discovered days later from a backed-up fulfillment queue. Nothing gets reactivated automatically. A human confirms the receiver is healthy first, and only a long-disabled hook gets recreated fresh rather than trusted as-is.

FAQ

Why did my BigCommerce order webhooks just stop, with nothing in the control panel?

BigCommerce retries a failing webhook destination on a backoff schedule for roughly 48 hours and then permanently flips that subscription's is_active to false. The only notice is an email to the address on file for the subscribing app, nothing appears in the store control panel, so orders keep being created normally while the hook silently stops delivering.

My hooks still show is_active true, so why are events missing?

BigCommerce also tracks a rolling 2 minute success and failure ratio per destination domain once it has sent 100 or more requests, and will blocklist the whole domain for 3 minutes if the success rate drops below 90 percent. During that window deliveries fail even though the hook record itself still reads is_active true, which looks identical to everything just stopping.

Is it safe to just flip is_active back to true on a deactivated hook?

No. The underlying delivery failure, a bad endpoint, expired auth, or a TLS or certificate issue, is still unresolved, so reactivating blindly just restarts the same failure loop. Confirm the destination returns a healthy response first, then reactivate with a guarded PUT wrapped in a dry run, and if the hook has been disabled long enough to risk 90 day pruning, recreate it fresh with POST instead of trusting the old record.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: how to avoid webhooks deactivation, it happens periodically. support.bigcommerce.com how to avoid webhooks deactivation
  2. BigCommerce Support: webhooks stopped firing today. support.bigcommerce.com webhooks stopped firing today
  3. BigCommerce Support: order webhook not firing. support.bigcommerce.com order webhook not firing

On the solution:

  1. BigCommerce Developer Center: webhooks overview, retry schedule, and deactivation. developer.bigcommerce.com webhooks overview
  2. BigCommerce Docs: webhooks overview. docs.bigcommerce.com webhooks overview
  3. BigCommerce Developer Center: webhooks admin, the /v3/hooks endpoints. developer.bigcommerce.com webhooks admin

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 silent webhook gap?

If this saved you from a mystery fulfillment backlog or caught a deactivated hook before it cost you a day, 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