Diagnostic Pricing / Price Lists

Price list changes fire no product or SKU webhooks

A price list record's price changes. The storefront starts showing the new number. But no store/product/updated event lands, no store/sku/updated event lands, and a catalog-sync integration that only listens for those two scopes never learns the price moved at all. BigCommerce Price Lists are a pricing overlay resolved at cart and storefront time, not a write to the product or variant row, so the change fires its own separate webhook family instead, one most integrations never subscribed to. Here is why that gap opens up and a script that finds every price list change your webhooks missed.

Python and Node.js BigCommerce V3 Price Lists API Report only (no auto-write)
Futuristic circuit board with glowing green data beam.
Photo by Brecht Corbeel on Unsplash
The short answer

Writing a record to /v3/pricelists/{price_list_id}/records never touches the base product or variant object, so it never bumps date_modified and never emits store/product/updated or store/sku/updated. Price list writes instead fire their own scopes, store/priceList/record/created, store/priceList/record/updated, store/priceList/record/deleted for single writes, and store/priceList/records/created for batch writes. A catalog-sync integration that only subscribed to product and SKU scopes is structurally blind to every price change that flows through a price list. Run a small Python or Node.js script that checks GET /v3/hooks for the missing scopes, pulls every price list's current records with GET /v3/pricelists/{price_list_id}/records, diffs that snapshot against the last one, and reports each changed (price_list_id, variant_id) pair the integration's webhooks never saw. Full code, tests, and the pure diff function are below.

The problem in plain words

In BigCommerce, a product's and a variant's price live on the catalog object itself, at /v3/catalog/products/{id} and /v3/catalog/products/{id}/variants/{id}. Any write there is a genuine mutation of the base catalog row, it bumps date_modified, and it fires the store/product/updated or store/sku/updated webhook that catalog-sync integrations already listen on.

Price Lists work differently on purpose. A price list is an overlay: a separate set of price, sale_price, retail_price, and map_price records, keyed by variant_id, that BigCommerce resolves against the base catalog price at cart and storefront time, usually to support customer-group or currency-specific pricing. Writing a record to a price list through POST or PUT /v3/pricelists/{price_list_id}/records only ever touches that overlay row. The product and variant underneath are never written to, so nothing about them changes, no date_modified bump, no store/product/updated, no store/sku/updated. The price the shopper sees is different, but from the base catalog's point of view, nothing happened.

Write price list /pricelists/{id}/records Overlay updated price, sale_price, map Never touches catalog row product/sku unchanged date_modified unchanged No product/ sku webhook fires Integration only subscribed to store/product/* and store/sku/* scopes: change is invisible.
The price list overlay changes, the storefront price changes with it, but the base catalog row never moves, so nothing fires on the scopes most integrations already listen on.

Why it happens

Price Lists were built as an overlay mechanism on purpose, to support customer-group and currency-specific pricing without duplicating whole product records. That design choice has a side effect most integrations do not anticipate:

The result is a silent gap: the storefront price is correct, the price list record is correct, but any system relying on product or SKU webhooks to know when to refresh a pricing cache, re-sync an ERP, or re-index a search catalog never gets the signal. Teams often discover this only after a customer complains a price is stale somewhere downstream. See the citations at the end for the exact docs.

The key insight

A subscription to store/product/updated or store/sku/updated says nothing about whether store/priceList/record/updated is also registered. Those are independent webhook scopes on independent resources. So the safe pattern is not "watch the catalog webhooks harder." It is "check whether the price list scopes are registered at all, and if they are not, treat every price list record as something you must diff yourself." We call GET /v3/hooks to see what is actually registered, snapshot GET /v3/pricelists/{price_list_id}/records on a schedule, and diff snapshots to find what changed, exactly the same information the missing webhook would have carried.

The fix, as a flow

We do not write to the catalog and we do not synthesize a product or SKU webhook. We add a job that checks which webhook scopes are active, snapshots every price list's records, diffs the current snapshot against the previous one, and reports every changed record where the active scopes prove the integration would have missed it.

Scheduled job runs on a timer GET /v3/hooks check active scopes and price lists' records Diff snapshots previous vs current Changed and scopes missing? yes no, skip Finding logged price_list_id, variant_id
The script only writes a finding when a record actually changed and the store's active hooks prove the change was invisible to catalog webhooks. Nothing in the catalog is ever touched.

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 Price Lists (read) and Webhooks (read) scope, since this job only reads price list records and inspects registered hooks, it never writes to the catalog. Keep the store hash and access token in environment variables, sent on every call as the X-Auth-Token header.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export SNAPSHOT_PATH="price_list_snapshot.json"
export DRY_RUN="true"   # start safe, change to false to register missing hooks
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export SNAPSHOT_PATH="price_list_snapshot.json"
export DRY_RUN="true"   // start safe, change to false to register missing hooks
2

Talk to the V3 REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. V3 responses wrap the payload in {data, meta.pagination}. A small helper handles GET and POST and raises on a non-2xx response, and we reuse it to list hooks, list price lists, and pull records.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

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

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()

def bc_post(path, body):
    r = requests.post(f"{API_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 = `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(path, params = {}) {
  const url = new URL(`${API_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}`);
  return res.json();
}

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

List active hooks and every price list's current records

Call GET /v3/hooks and collect the scope of every hook with is_active: true, that is the set the diff function checks against. Then call GET /v3/pricelists, paginated via meta.pagination, to get each price_list_id, and for each one page through GET /v3/pricelists/{price_list_id}/records?limit=250 to build the current snapshot keyed by (price_list_id, variant_id).

step3.py
def active_hook_scopes():
    scopes = set()
    page = 1
    while True:
        payload = bc_get("/hooks", {"page": page, "limit": 250})
        rows = payload.get("data", [])
        if not rows:
            return scopes
        for hook in rows:
            if hook.get("is_active"):
                scopes.add(hook.get("scope"))
        page += 1

def all_price_list_ids():
    page = 1
    while True:
        payload = bc_get("/pricelists", {"page": page, "limit": 250})
        rows = payload.get("data", [])
        if not rows:
            return
        for price_list in rows:
            yield price_list["id"]
        page += 1

def price_list_snapshot():
    snapshot = {}
    for price_list_id in all_price_list_ids():
        page = 1
        while True:
            payload = bc_get(f"/pricelists/{price_list_id}/records", {"page": page, "limit": 250})
            rows = payload.get("data", [])
            if not rows:
                break
            for record in rows:
                key = (price_list_id, record["variant_id"])
                snapshot[key] = {
                    "price": str(record.get("price", "")),
                    "sale_price": str(record.get("sale_price", "")),
                    "retail_price": str(record.get("retail_price", "")),
                    "map_price": str(record.get("map_price", "")),
                    "currency": record.get("currency", ""),
                }
            page += 1
    return snapshot
step3.js
async function activeHookScopes() {
  const scopes = new Set();
  let page = 1;
  while (true) {
    const payload = await bcGet("/hooks", { page, limit: 250 });
    const rows = payload.data || [];
    if (!rows.length) return scopes;
    for (const hook of rows) if (hook.is_active) scopes.add(hook.scope);
    page += 1;
  }
}

async function* allPriceListIds() {
  let page = 1;
  while (true) {
    const payload = await bcGet("/pricelists", { page, limit: 250 });
    const rows = payload.data || [];
    if (!rows.length) return;
    for (const priceList of rows) yield priceList.id;
    page += 1;
  }
}

async function priceListSnapshot() {
  const snapshot = {};
  for await (const priceListId of allPriceListIds()) {
    let page = 1;
    while (true) {
      const payload = await bcGet(`/pricelists/${priceListId}/records`, { page, limit: 250 });
      const rows = payload.data || [];
      if (!rows.length) break;
      for (const record of rows) {
        const key = `${priceListId}:${record.variant_id}`;
        snapshot[key] = {
          price_list_id: priceListId,
          variant_id: record.variant_id,
          price: String(record.price ?? ""),
          sale_price: String(record.sale_price ?? ""),
          retail_price: String(record.retail_price ?? ""),
          map_price: String(record.map_price ?? ""),
          currency: record.currency || "",
        };
      }
      page += 1;
    }
  }
  return snapshot;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the previous snapshot, the current snapshot, and the set of active webhook scopes, and returns the list of findings. A record counts as changed if any money field differs or if it is brand new. A finding is flagged as a webhook gap only when the store is watching store/product/updated or store/sku/updated but has none of the three price list scopes registered, exactly the mismatch that proves catalog-only subscribers are blind to the change.

decide.py
MONEY_FIELDS = ("price", "sale_price", "retail_price", "map_price")
PRICE_LIST_SCOPES = {
    "store/priceList/record/created",
    "store/priceList/record/updated",
    "store/priceList/records/created",
}

def diff_price_list_records(previous, current, watched_scopes):
    watches_catalog = bool(watched_scopes & {"store/product/updated", "store/sku/updated"})
    watches_price_lists = bool(watched_scopes & PRICE_LIST_SCOPES)
    webhook_gap = watches_catalog and not watches_price_lists

    findings = []
    for key, cur_record in current.items():
        prev_record = previous.get(key)
        changed_fields = [
            field for field in MONEY_FIELDS
            if prev_record is None or prev_record.get(field) != cur_record.get(field)
        ]
        if not changed_fields:
            continue
        price_list_id, variant_id = key
        findings.append({
            "price_list_id": price_list_id,
            "variant_id": variant_id,
            "changed_fields": changed_fields,
            "webhook_gap": webhook_gap,
        })
    return findings
decide.js
const MONEY_FIELDS = ["price", "sale_price", "retail_price", "map_price"];
const PRICE_LIST_SCOPES = new Set([
  "store/priceList/record/created",
  "store/priceList/record/updated",
  "store/priceList/records/created",
]);

export function diffPriceListRecords(previous, current, watchedScopes) {
  const watchesCatalog = watchedScopes.has("store/product/updated") || watchedScopes.has("store/sku/updated");
  const watchesPriceLists = [...PRICE_LIST_SCOPES].some((scope) => watchedScopes.has(scope));
  const webhookGap = watchesCatalog && !watchesPriceLists;

  const findings = [];
  for (const [key, curRecord] of Object.entries(current)) {
    const prevRecord = previous[key];
    const changedFields = MONEY_FIELDS.filter(
      (field) => !prevRecord || prevRecord[field] !== curRecord[field]
    );
    if (!changedFields.length) continue;
    findings.push({
      price_list_id: curRecord.price_list_id,
      variant_id: curRecord.variant_id,
      changed_fields: changedFields,
      webhook_gap: webhookGap,
    });
  }
  return findings;
}
5

Report, do not auto-write catalog data

Log every finding with enough detail, price_list_id, variant_id, changed_fields, and a detected_at timestamp, for the consuming team to reconcile pricing caches out of band. Never synthesize a store/product/updated or store/sku/updated event for the affected products or variants. That would misrepresent catalog state to systems that key off those webhooks specifically for catalog, not pricing, changes.

apply.py
def register_price_list_hooks(destination):
    for scope in sorted(PRICE_LIST_SCOPES):
        bc_post("/hooks", {"scope": scope, "destination": destination, "is_active": True})
apply.js
async function registerPriceListHooks(destination) {
  for (const scope of [...PRICE_LIST_SCOPES].sort()) {
    await bcPost("/hooks", { scope, destination, is_active: true });
  }
}
6

Wire it together with a dry run guard

The run loop pulls the active scopes, loads the last saved snapshot from disk, builds the current snapshot, diffs them, logs every finding, and writes the new snapshot back to disk for next time. Notice the dry run guard on the one write this job is allowed to make. On the first few runs, leave DRY_RUN on so the script only logs which hook scopes it would register. Read the output, confirm the destination endpoint is correct, then switch it off. Run the snapshot-and-diff step on a schedule that matches how often your price lists actually change, for example every 15 minutes.

Run it safe

Always start with DRY_RUN=true, and never let this job write to /v3/catalog/products or /v3/catalog/products/{id}/variants to backfill a product or SKU event. The only write this job ever makes, once you turn DRY_RUN off, is registering the missing store/priceList/* hook subscriptions.

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 is safe to run again and again because it only reports changed records and only writes new webhook subscriptions, never catalog data.

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

detect_price_list_webhook_gap.py
"""Detect BigCommerce price list changes that fired no product or SKU webhook.

BigCommerce Price Lists are a pricing overlay resolved at cart and storefront
time, not a mutation of the base catalog object. Writing a price list record
through POST or PUT /v3/pricelists/{price_list_id}/records never touches the
product or variant row, so it never bumps date_modified and never emits
store/product/updated or store/sku/updated. Price list changes instead fire
their own webhook family, store/priceList/record/created|updated|deleted for
single writes and store/priceList/records/created for batch writes, which most
catalog-sync integrations never subscribe to because they assumed all pricing
changes surface through the product/SKU scopes they already listen on. This job
checks which scopes are actually active, snapshots every price list's records,
diffs the snapshot against the previous run, and reports every changed record
where the active scopes prove the change was invisible to catalog webhooks. It
never writes to the catalog and never synthesizes a product or SKU event; the
only write it can make, guarded by DRY_RUN, is registering the missing
store/priceList/* hook subscriptions.

Guide: https://www.allanninal.dev/bigcommerce/price-list-changes-fire-no-webhooks/
"""
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
SNAPSHOT_PATH = os.environ.get("SNAPSHOT_PATH", "price_list_snapshot.json")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HOOK_DESTINATION = os.environ.get("HOOK_DESTINATION", "")

MONEY_FIELDS = ("price", "sale_price", "retail_price", "map_price")
PRICE_LIST_SCOPES = {
    "store/priceList/record/created",
    "store/priceList/record/updated",
    "store/priceList/record/deleted",
    "store/priceList/records/created",
}
CATALOG_SCOPES = {"store/product/updated", "store/sku/updated"}

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


def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()


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


def diff_price_list_records(
    previous: dict, current: dict, watched_scopes: set
) -> list:
    """Pure decision. No network, no side effects.

    previous/current: map of (price_list_id, variant_id) -> {"price", "sale_price",
    "retail_price", "map_price", "currency"} (money as decimal strings).
    watched_scopes: set of hook scopes currently registered active on the store.

    A record is "changed" if any of price/sale_price/retail_price/map_price
    differs between previous and current for the same (price_list_id, variant_id),
    or if the key exists only in current (new record). The change is "invisible to
    catalog webhooks" if watched_scopes contains store/product/updated or
    store/sku/updated but does not contain any of the price list record scopes.
    Returns a list of finding dicts, most relevant for reporting.
    """
    watches_catalog = bool(watched_scopes & CATALOG_SCOPES)
    watches_price_lists = bool(watched_scopes & PRICE_LIST_SCOPES)
    webhook_gap = watches_catalog and not watches_price_lists

    findings = []
    for key, cur_record in current.items():
        prev_record = previous.get(key)
        changed_fields = [
            field
            for field in MONEY_FIELDS
            if prev_record is None or prev_record.get(field) != cur_record.get(field)
        ]
        if not changed_fields:
            continue
        price_list_id, variant_id = key
        findings.append(
            {
                "price_list_id": price_list_id,
                "variant_id": variant_id,
                "changed_fields": changed_fields,
                "webhook_gap": webhook_gap,
            }
        )
    return findings


def active_hook_scopes():
    scopes = set()
    page = 1
    while True:
        payload = bc_get("/hooks", {"page": page, "limit": 250})
        rows = payload.get("data", [])
        if not rows:
            return scopes
        for hook in rows:
            if hook.get("is_active"):
                scopes.add(hook.get("scope"))
        page += 1


def all_price_list_ids():
    page = 1
    while True:
        payload = bc_get("/pricelists", {"page": page, "limit": 250})
        rows = payload.get("data", [])
        if not rows:
            return
        for price_list in rows:
            yield price_list["id"]
        page += 1


def price_list_snapshot():
    snapshot = {}
    for price_list_id in all_price_list_ids():
        page = 1
        while True:
            payload = bc_get(
                f"/pricelists/{price_list_id}/records", {"page": page, "limit": 250}
            )
            rows = payload.get("data", [])
            if not rows:
                break
            for record in rows:
                key = (price_list_id, record["variant_id"])
                snapshot[key] = {
                    "price": str(record.get("price", "")),
                    "sale_price": str(record.get("sale_price", "")),
                    "retail_price": str(record.get("retail_price", "")),
                    "map_price": str(record.get("map_price", "")),
                    "currency": record.get("currency", ""),
                }
            page += 1
    return snapshot


def load_previous_snapshot():
    path = Path(SNAPSHOT_PATH)
    if not path.exists():
        return {}
    raw = json.loads(path.read_text())
    return {tuple(item["key"]): item["record"] for item in raw}


def save_snapshot(snapshot):
    raw = [{"key": list(key), "record": record} for key, record in snapshot.items()]
    Path(SNAPSHOT_PATH).write_text(json.dumps(raw, indent=2))


def register_price_list_hooks(destination):
    created = []
    for scope in sorted(PRICE_LIST_SCOPES):
        bc_post("/hooks", {"scope": scope, "destination": destination, "is_active": True})
        created.append(scope)
    return created


def run():
    watched_scopes = active_hook_scopes()
    previous_snapshot = load_previous_snapshot()
    current_snapshot = price_list_snapshot()

    findings = diff_price_list_records(previous_snapshot, current_snapshot, watched_scopes)
    detected_at = datetime.now(timezone.utc).isoformat()

    for finding in findings:
        log.info(
            "price_list_id=%s variant_id=%s changed_fields=%s webhook_gap=%s detected_at=%s",
            finding["price_list_id"],
            finding["variant_id"],
            ",".join(finding["changed_fields"]),
            finding["webhook_gap"],
            detected_at,
        )

    gap_count = sum(1 for f in findings if f["webhook_gap"])
    if gap_count and HOOK_DESTINATION:
        log.warning(
            "%d changed record(s) invisible to catalog webhooks. Missing scopes: %s",
            gap_count, sorted(PRICE_LIST_SCOPES),
        )
        if not DRY_RUN:
            registered = register_price_list_hooks(HOOK_DESTINATION)
            log.info("Registered hook scopes: %s", registered)
        else:
            log.info("Dry run, would register hook scopes: %s", sorted(PRICE_LIST_SCOPES))

    save_snapshot(current_snapshot)
    log.info(
        "Done. %d changed record(s), %d flagged as a webhook gap.",
        len(findings), gap_count,
    )


if __name__ == "__main__":
    run()
detect-price-list-webhook-gap.js
/**
 * Detect BigCommerce price list changes that fired no product or SKU webhook.
 *
 * BigCommerce Price Lists are a pricing overlay resolved at cart and storefront
 * time, not a mutation of the base catalog object. Writing a price list record
 * through POST or PUT /v3/pricelists/{price_list_id}/records never touches the
 * product or variant row, so it never bumps date_modified and never emits
 * store/product/updated or store/sku/updated. Price list changes instead fire
 * their own webhook family, store/priceList/record/created|updated|deleted for
 * single writes and store/priceList/records/created for batch writes, which most
 * catalog-sync integrations never subscribe to because they assumed all pricing
 * changes surface through the product/SKU scopes they already listen on. This job
 * checks which scopes are actually active, snapshots every price list's records,
 * diffs the snapshot against the previous run, and reports every changed record
 * where the active scopes prove the change was invisible to catalog webhooks. It
 * never writes to the catalog and never synthesizes a product or SKU event; the
 * only write it can make, guarded by DRY_RUN, is registering the missing
 * store/priceList/* hook subscriptions.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/price-list-changes-fire-no-webhooks/
 */
import { readFile, writeFile } from "node:fs/promises";
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 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const SNAPSHOT_PATH = process.env.SNAPSHOT_PATH || "price_list_snapshot.json";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HOOK_DESTINATION = process.env.HOOK_DESTINATION || "";

const MONEY_FIELDS = ["price", "sale_price", "retail_price", "map_price"];
const PRICE_LIST_SCOPES = new Set([
  "store/priceList/record/created",
  "store/priceList/record/updated",
  "store/priceList/record/deleted",
  "store/priceList/records/created",
]);
const CATALOG_SCOPES = new Set(["store/product/updated", "store/sku/updated"]);

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

/**
 * Pure decision. No network, no side effects.
 *
 * previous/current: map of "price_list_id:variant_id" -> { price_list_id,
 * variant_id, price, sale_price, retail_price, map_price, currency } (money as
 * decimal strings). watchedScopes: Set of hook scopes currently registered
 * active on the store.
 *
 * A record is "changed" if any of price/sale_price/retail_price/map_price
 * differs between previous and current for the same key, or if the key exists
 * only in current (new record). The change is "invisible to catalog webhooks"
 * if watchedScopes contains store/product/updated or store/sku/updated but does
 * not contain any of the price list record scopes. Returns a list of finding
 * objects, most relevant for reporting.
 */
export function diffPriceListRecords(previous, current, watchedScopes) {
  const watchesCatalog = [...CATALOG_SCOPES].some((scope) => watchedScopes.has(scope));
  const watchesPriceLists = [...PRICE_LIST_SCOPES].some((scope) => watchedScopes.has(scope));
  const webhookGap = watchesCatalog && !watchesPriceLists;

  const findings = [];
  for (const [key, curRecord] of Object.entries(current)) {
    const prevRecord = previous[key];
    const changedFields = MONEY_FIELDS.filter(
      (field) => !prevRecord || prevRecord[field] !== curRecord[field]
    );
    if (!changedFields.length) continue;
    findings.push({
      price_list_id: curRecord.price_list_id,
      variant_id: curRecord.variant_id,
      changed_fields: changedFields,
      webhook_gap: webhookGap,
    });
  }
  return findings;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_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}`);
  return res.json();
}

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

async function activeHookScopes() {
  const scopes = new Set();
  let page = 1;
  while (true) {
    const payload = await bcGet("/hooks", { page, limit: 250 });
    const rows = payload.data || [];
    if (!rows.length) return scopes;
    for (const hook of rows) if (hook.is_active) scopes.add(hook.scope);
    page += 1;
  }
}

async function* allPriceListIds() {
  let page = 1;
  while (true) {
    const payload = await bcGet("/pricelists", { page, limit: 250 });
    const rows = payload.data || [];
    if (!rows.length) return;
    for (const priceList of rows) yield priceList.id;
    page += 1;
  }
}

async function priceListSnapshot() {
  const snapshot = {};
  for await (const priceListId of allPriceListIds()) {
    let page = 1;
    while (true) {
      const payload = await bcGet(`/pricelists/${priceListId}/records`, { page, limit: 250 });
      const rows = payload.data || [];
      if (!rows.length) break;
      for (const record of rows) {
        const key = `${priceListId}:${record.variant_id}`;
        snapshot[key] = {
          price_list_id: priceListId,
          variant_id: record.variant_id,
          price: String(record.price ?? ""),
          sale_price: String(record.sale_price ?? ""),
          retail_price: String(record.retail_price ?? ""),
          map_price: String(record.map_price ?? ""),
          currency: record.currency || "",
        };
      }
      page += 1;
    }
  }
  return snapshot;
}

async function loadPreviousSnapshot() {
  try {
    const raw = await readFile(SNAPSHOT_PATH, "utf8");
    return JSON.parse(raw);
  } catch {
    return {};
  }
}

async function saveSnapshot(snapshot) {
  await writeFile(SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2));
}

async function registerPriceListHooks(destination) {
  const created = [];
  for (const scope of [...PRICE_LIST_SCOPES].sort()) {
    await bcPost("/hooks", { scope, destination, is_active: true });
    created.push(scope);
  }
  return created;
}

export async function run() {
  const watchedScopes = await activeHookScopes();
  const previousSnapshot = await loadPreviousSnapshot();
  const currentSnapshot = await priceListSnapshot();

  const findings = diffPriceListRecords(previousSnapshot, currentSnapshot, watchedScopes);
  const detectedAt = new Date().toISOString();

  for (const finding of findings) {
    console.log(
      `price_list_id=${finding.price_list_id} variant_id=${finding.variant_id} ` +
      `changed_fields=${finding.changed_fields.join(",")} webhook_gap=${finding.webhook_gap} ` +
      `detected_at=${detectedAt}`
    );
  }

  const gapCount = findings.filter((f) => f.webhook_gap).length;
  if (gapCount && HOOK_DESTINATION) {
    console.warn(
      `${gapCount} changed record(s) invisible to catalog webhooks. Missing scopes: ${[...PRICE_LIST_SCOPES].sort()}`
    );
    if (!DRY_RUN) {
      const registered = await registerPriceListHooks(HOOK_DESTINATION);
      console.log(`Registered hook scopes: ${registered}`);
    } else {
      console.log(`Dry run, would register hook scopes: ${[...PRICE_LIST_SCOPES].sort()}`);
    }
  }

  await saveSnapshot(currentSnapshot);
  console.log(`Done. ${findings.length} changed record(s), ${gapCount} flagged as a webhook gap.`);
}

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

Add a test

The diff rule is the part most worth testing, because it decides which findings a team ends up acting on. Because diff_price_list_records takes only plain values and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain dicts and checks the findings.

test_price_list_webhook_gap.py
from detect_price_list_webhook_gap import diff_price_list_records


def record(price="10.00", sale_price="10.00", retail_price="12.00", map_price="", currency="USD"):
    return {"price": price, "sale_price": sale_price, "retail_price": retail_price,
            "map_price": map_price, "currency": currency}


CATALOG_ONLY = {"store/product/updated", "store/sku/updated"}
CATALOG_AND_PRICE_LIST = {"store/product/updated", "store/priceList/record/updated"}


def test_no_findings_when_nothing_changed():
    previous = {(1, 100): record()}
    current = {(1, 100): record()}
    assert diff_price_list_records(previous, current, CATALOG_ONLY) == []


def test_finds_changed_price_and_flags_webhook_gap_when_only_catalog_scopes_watched():
    previous = {(1, 100): record(price="10.00")}
    current = {(1, 100): record(price="12.00")}
    findings = diff_price_list_records(previous, current, CATALOG_ONLY)
    assert len(findings) == 1
    assert findings[0]["price_list_id"] == 1
    assert findings[0]["variant_id"] == 100
    assert findings[0]["changed_fields"] == ["price"]
    assert findings[0]["webhook_gap"] is True


def test_no_webhook_gap_when_price_list_scope_is_also_registered():
    previous = {(1, 100): record(price="10.00")}
    current = {(1, 100): record(price="12.00")}
    findings = diff_price_list_records(previous, current, CATALOG_AND_PRICE_LIST)
    assert findings[0]["webhook_gap"] is False


def test_no_webhook_gap_when_no_catalog_scopes_are_watched_at_all():
    previous = {(1, 100): record(price="10.00")}
    current = {(1, 100): record(price="12.00")}
    findings = diff_price_list_records(previous, current, set())
    assert findings[0]["webhook_gap"] is False


def test_new_record_counts_as_changed():
    previous = {}
    current = {(2, 200): record()}
    findings = diff_price_list_records(previous, current, CATALOG_ONLY)
    assert len(findings) == 1
    assert findings[0]["price_list_id"] == 2
    assert findings[0]["variant_id"] == 200


def test_multiple_money_fields_are_all_reported():
    previous = {(1, 100): record(price="10.00", sale_price="9.00")}
    current = {(1, 100): record(price="12.00", sale_price="11.00")}
    findings = diff_price_list_records(previous, current, CATALOG_ONLY)
    assert set(findings[0]["changed_fields"]) == {"price", "sale_price"}
detect-price-list-webhook-gap.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffPriceListRecords } from "./detect-price-list-webhook-gap.js";

const record = ({
  price_list_id = 1, variant_id = 100, price = "10.00", sale_price = "10.00",
  retail_price = "12.00", map_price = "", currency = "USD",
} = {}) => ({ price_list_id, variant_id, price, sale_price, retail_price, map_price, currency });

const CATALOG_ONLY = new Set(["store/product/updated", "store/sku/updated"]);
const CATALOG_AND_PRICE_LIST = new Set(["store/product/updated", "store/priceList/record/updated"]);

test("no findings when nothing changed", () => {
  const previous = { "1:100": record() };
  const current = { "1:100": record() };
  assert.deepEqual(diffPriceListRecords(previous, current, CATALOG_ONLY), []);
});

test("finds changed price and flags webhook gap when only catalog scopes watched", () => {
  const previous = { "1:100": record({ price: "10.00" }) };
  const current = { "1:100": record({ price: "12.00" }) };
  const findings = diffPriceListRecords(previous, current, CATALOG_ONLY);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].price_list_id, 1);
  assert.equal(findings[0].variant_id, 100);
  assert.deepEqual(findings[0].changed_fields, ["price"]);
  assert.equal(findings[0].webhook_gap, true);
});

test("no webhook gap when price list scope is also registered", () => {
  const previous = { "1:100": record({ price: "10.00" }) };
  const current = { "1:100": record({ price: "12.00" }) };
  const findings = diffPriceListRecords(previous, current, CATALOG_AND_PRICE_LIST);
  assert.equal(findings[0].webhook_gap, false);
});

test("no webhook gap when no catalog scopes are watched at all", () => {
  const previous = { "1:100": record({ price: "10.00" }) };
  const current = { "1:100": record({ price: "12.00" }) };
  const findings = diffPriceListRecords(previous, current, new Set());
  assert.equal(findings[0].webhook_gap, false);
});

test("new record counts as changed", () => {
  const previous = {};
  const current = { "2:200": record({ price_list_id: 2, variant_id: 200 }) };
  const findings = diffPriceListRecords(previous, current, CATALOG_ONLY);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].price_list_id, 2);
  assert.equal(findings[0].variant_id, 200);
});

test("multiple money fields are all reported", () => {
  const previous = { "1:100": record({ price: "10.00", sale_price: "9.00" }) };
  const current = { "1:100": record({ price: "12.00", sale_price: "11.00" }) };
  const findings = diffPriceListRecords(previous, current, CATALOG_ONLY);
  assert.deepEqual(new Set(findings[0].changed_fields), new Set(["price", "sale_price"]));
});

Case studies

ERP sync gone stale

The store whose ERP price sync only ever watched products

A mid-size store synced BigCommerce prices into its ERP by subscribing to store/product/updated and store/sku/updated, which worked fine for years because pricing changes always went through the base catalog. Then the merchandising team adopted price lists for a wholesale customer group, and the ERP's mirrored prices for those SKUs quietly went stale, no error, no failed webhook, just silence.

The gap only surfaced when a wholesale customer flagged a price mismatch. Running the detector against the store's active hooks immediately confirmed the mismatch: store/product/updated was registered, none of the store/priceList/record/* scopes were. The report gave the exact price_list_id and variant_id pairs the ERP had missed, and the team registered the missing hooks the same day.

Search index drift

The search index that trusted store/sku/updated for pricing

A storefront's search index re-scored and re-cached product pricing on store/sku/updated, and that had been the only signal it ever needed. When the store rolled out currency-specific price lists for an international launch, the search index kept serving the old default-currency price for every SKU with an active price list record, because those changes never touched the SKU row.

A scheduled run of the snapshot-and-diff job caught it within 15 minutes of the first price list update, flagged every affected variant_id as a webhook gap, and the team wired the search index's cache invalidation to the correct store/priceList/record/updated scope instead of assuming SKU webhooks covered every price change.

What good looks like

After this runs on a schedule, no price list change goes unnoticed even though it never touches the base catalog object. The moment a store subscribes to product or SKU webhooks without also subscribing to the price list scopes, the detector says so explicitly, with the exact price_list_id and variant_id pairs affected, so a team can register the missing hooks and reconcile any pricing cache that drifted, without ever having to guess at or fabricate a catalog event that never actually happened.

FAQ

Why does changing a BigCommerce price list not fire a product or SKU webhook?

Price Lists are a pricing overlay resolved at cart and storefront time, not a mutation of the base product or variant row. Writing a record with POST or PUT to /v3/pricelists/{price_list_id}/records never touches the product or variant, so it never bumps date_modified and never emits store/product/updated or store/sku/updated. The change is real, but it lives entirely in its own price list webhook family.

Which webhook scopes actually fire when a price list record changes?

Single-record writes fire store/priceList/record/created, store/priceList/record/updated, or store/priceList/record/deleted. Batch writes fire store/priceList/records/created. Most catalog-sync integrations only subscribe to store/product/* and store/sku/* scopes, so they never register these price list scopes and silently miss every price change that flows through a price list instead of the base catalog.

Should a script backfill store/product/updated events for the affected products?

No. Synthesizing a product webhook for a pricing-only change would misrepresent catalog state to downstream systems that key off product webhooks specifically for catalog changes, not pricing changes. The safe corrective action is to detect the gap, report it per price_list_id and variant_id, and register the missing store/priceList/record/* and store/priceList/records/created subscriptions so the integration hears about pricing changes on the channel BigCommerce actually uses for them.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Price Lists overview, the pricing overlay model. developer.bigcommerce.com price lists
  2. BigCommerce Developer Center: webhook events reference, including the store/priceList/* scopes. developer.bigcommerce.com webhook events
  3. Hypa: the practitioner's guide to BigCommerce webhooks, on scope coverage gaps in real integrations. hypaapps.com practitioner's guide to BigCommerce webhooks

On the solution:

  1. BigCommerce Developer Center: Price Lists Records endpoints, reading and writing records. developer.bigcommerce.com price lists records
  2. BigCommerce Developer Center: webhooks overview, registering and managing hook subscriptions. developer.bigcommerce.com webhooks overview
  3. BigCommerce Developer Center: Price List API overview. developer.bigcommerce.com price list API overview

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, or pricing 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 pricing gap you would have missed?

If this saved you from a stale pricing cache or caught a webhook gap you would have otherwise missed, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all BigCommerce field notes