Reconciler Coupons / Promotions

BigCommerce coupon applies_to wiped when you edit max_uses

A merchant needed to bump a coupon's usage cap, so a script sent PUT /v2/coupons/{id} with just {"max_uses": 50}. The response came back 200, and the max_uses field updated fine. What nobody noticed until the coupon started applying store-wide was that its product restriction was gone. BigCommerce's legacy Coupons endpoint treats PUT as a full replace, not a patch, so any field you leave out of the body, especially applies_to, gets reset to its default. Here is why that happens and a reconciler that always re-sends the untouched fields so this can not happen again.

Python and Node.js BigCommerce V2 Coupons API Safe by default (dry run)
Red and white love print textile
Photo by Tamanna Rumee on Unsplash
The short answer

BigCommerce's V2 Coupons endpoint (PUT /stores/{store_hash}/v2/coupons/{id}) is a full-object replace, not a partial patch, for the applies_to sub-object. BigCommerce's own docs say plainly that if applies_to "is not included in the PUT request, its existing value on the coupon will be cleared." So a request that sends only {"max_uses": 50} silently resets applies_to back to its default, wiping the coupon's product or category restriction while the response still shows 200 and every other field intact. The fix is a reconciler pattern, always GET the coupon immediately before a write, merge the fresh snapshot's applies_to (and every other untouched field) into the PUT body, then verify with a follow-up GET and diff. Full code, tests, and a dry run guard are below.

The problem in plain words

A BigCommerce coupon has a field called applies_to, an object shaped like {"entity": "products", "ids": [123, 456]}, that restricts the coupon to specific products or categories. It sits alongside ordinary fields like max_uses, amount, and type on the same coupon record.

The trouble starts when someone needs to change just one of those ordinary fields, most often max_uses, because a promotion is more popular than expected and the usage cap needs raising. The obvious move is to send a small PUT with only the field that changed: {"max_uses": 50}. On most modern REST APIs that is exactly what a partial update looks like. On the legacy V2 Coupons endpoint, it is not. The endpoint replaces the whole coupon object with whatever body you send, and any field you did not include, applies_to most of all, gets reset to its default rather than left alone. The response is still a 200, and it still echoes back max_uses: 50 correctly, so nothing in the API response looks wrong. The only sign something broke is that the coupon, which used to apply to a specific handful of SKUs, is suddenly valid store-wide.

PUT /v2/coupons/1 body: max_uses only Endpoint treats it as a full replace applies_to omitted applies_to cleared default entity/ids Response: 200 looks fine
The response is 200 and max_uses is correct, so nothing in the API call itself signals a problem. The coupon quietly stops being restricted to its intended products.

Why it happens

This is a documented, not accidental, behavior of the legacy endpoint. A few things make it easy to trip over:

The result is usually only caught once the coupon behaves wrong on the storefront, applying to items it should not, days or weeks after the original edit. See the citations at the end for the exact docs describing this field.

The key insight

Never send a partial body to this endpoint and assume untouched fields survive. Treat every PUT as a full replace and always compose it from a fresh snapshot of the coupon, merging in only the fields you actually intend to change. applies_to is the highest-risk field, but the same reconciler pattern protects every other field on the coupon too.

The fix, as a flow

We do not touch the coupon logic itself. We add a reconciler that snapshots every coupon before any write, re-fetches the freshest copy right before each PUT, always merges the snapshot into the request body, and verifies afterward that nothing unintended changed.

Snapshot all coupons first Re-GET fresh right before write Merge and PUT snapshot + desired change applies_to still matches? yes no, corrective PUT Verified clean diff confirms match
Every write is composed from a full snapshot, never a bare partial body, and a post-write GET confirms applies_to survived unchanged.

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 Marketing (modify) scope so it can read and update coupons. 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 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 DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V2 Coupons REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v2/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to list coupons, snapshot each one, and write the update.

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}/v2"

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() if r.text else []

def bc_put(path, body):
    r = requests.put(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}/v2`;

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}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

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

Snapshot every coupon before any write

Call GET /v2/coupons?limit=250&page=N, paginated, and store each coupon's id, code, type, amount, max_uses, num_uses, and full applies_to object keyed by coupon id. This snapshot is the reconciliation store you diff against after every write, and it is also the source of truth you merge from when composing a PUT body.

step3.py
def all_coupons():
    page = 1
    while True:
        coupons = bc_get("/coupons", {"limit": 250, "page": page})
        if not coupons:
            return
        for coupon in coupons:
            yield coupon
        page += 1

def snapshot_coupons():
    return {str(c["id"]): c for c in all_coupons()}
step3.js
async function* allCoupons() {
  let page = 1;
  while (true) {
    const coupons = await bcGet("/coupons", { limit: 250, page });
    if (!coupons.length) return;
    for (const coupon of coupons) yield coupon;
    page += 1;
  }
}

async function snapshotCoupons() {
  const snapshot = {};
  for await (const coupon of allCoupons()) snapshot[String(coupon.id)] = coupon;
  return snapshot;
}
4

Plan the update with one pure function

Keep the decision in its own function that takes a coupon snapshot and the desired changes, and always returns a full PUT body, never a bare partial. It merges desiredChanges on top of a full copy of the snapshot, so applies_to and every other untouched field are re-asserted on every write. It also reports which fields were at risk of being wiped, purely for logging.

plan.py
WIPE_RISK_FIELDS = ("applies_to",)

def plan_coupon_update(snapshot, desired_changes):
    body = dict(snapshot)
    body.update(desired_changes)
    body.pop("id", None)

    wipe_risk_fields = [
        field for field in WIPE_RISK_FIELDS
        if field in snapshot and field not in desired_changes
    ]

    return {
        "method": "PUT",
        "path": f"/coupons/{snapshot['id']}",
        "body": body,
        "wipeRiskFields": wipe_risk_fields,
    }
plan.js
const WIPE_RISK_FIELDS = ["applies_to"];

export function planCouponUpdate(snapshot, desiredChanges) {
  const body = { ...snapshot, ...desiredChanges };
  delete body.id;

  const wipeRiskFields = WIPE_RISK_FIELDS.filter(
    (field) => field in snapshot && !(field in desiredChanges)
  );

  return {
    method: "PUT",
    path: `/coupons/${snapshot.id}`,
    body,
    wipeRiskFields,
  };
}
5

Re-fetch fresh, send the merged PUT, then verify

Right before writing, re-GET /v2/coupons/{id} to get the freshest possible snapshot, plan the update from that, then send the PUT. Immediately after, GET the coupon again and diff its applies_to against what the plan intended. If ids came back empty or entity changed and that was not part of desired_changes, the write wiped something, and it needs a corrective PUT.

apply.py
def apply_coupon_update(coupon_id, desired_changes):
    fresh = bc_get(f"/coupons/{coupon_id}")
    plan = plan_coupon_update(fresh, desired_changes)
    bc_put(plan["path"], plan["body"])

    after = bc_get(f"/coupons/{coupon_id}")
    expected_applies_to = plan["body"].get("applies_to")
    wiped = expected_applies_to is not None and after.get("applies_to") != expected_applies_to
    return after, wiped
apply.js
async function applyCouponUpdate(couponId, desiredChanges) {
  const fresh = await bcGet(`/coupons/${couponId}`);
  const plan = planCouponUpdate(fresh, desiredChanges);
  await bcPut(plan.path, plan.body);

  const after = await bcGet(`/coupons/${couponId}`);
  const expectedAppliesTo = plan.body.applies_to;
  const wiped = expectedAppliesTo !== undefined &&
    JSON.stringify(after.applies_to) !== JSON.stringify(expectedAppliesTo);
  return { after, wiped };
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the intended PUT body and the wipe-risk fields for each coupon it would touch. Read the output, agree with it, then switch it off. If a wipe is ever detected after a real write, the script logs a corrective PUT payload rather than sending it automatically when DRY_RUN is on, and only sends the corrective PUT and re-verifies with a follow-up GET when DRY_RUN is off.

Run it safe

Always start with DRY_RUN=true, and never fabricate an applies_to value for a coupon that has no prior snapshot. Flag it for manual review instead. Guessing a restriction can be just as damaging as losing it.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because every write is composed from a fresh full snapshot, never a bare partial body.

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

reconcile_coupon_applies_to.py
"""Edit BigCommerce coupon max_uses without wiping applies_to.

The legacy V2 Coupons endpoint (PUT /stores/{store_hash}/v2/coupons/{id})
treats PUT as a full-object replace, not a true partial patch, for the
applies_to sub-object. BigCommerce's own docs state that if applies_to is
not included in the PUT request, its existing value on the coupon will be
cleared. A script that PUTs only {"max_uses": 50} to bump a usage cap
silently resets applies_to back to its default (entity "products" or
"categories" with an empty ids state), wiping the coupon's product or
category restriction. The response is still 200 and every other field
looks correct, so the loss is silent and usually only noticed once the
coupon starts applying store-wide.

This script snapshots every coupon before any write, re-fetches the
freshest copy right before each PUT, always composes the PUT body by
merging the snapshot into desired_changes (never a bare partial), and
verifies with a follow-up GET that applies_to survived. If a wipe is
detected, a corrective PUT resending the snapshotted applies_to is logged
(DRY_RUN=true) or sent and re-verified (DRY_RUN=false). Coupons with no
prior snapshot are flagged for manual review, never guessed.

Guide: https://www.allanninal.dev/bigcommerce/coupon-applies-to-wiped-on-max-uses-edit/
"""
import os
import logging

import requests

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

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

WIPE_RISK_FIELDS = ("applies_to",)

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()
    if not r.text:
        return []
    return r.json()


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


def plan_coupon_update(snapshot: dict, desired_changes: dict) -> dict:
    """Pure decision. No network, no side effects.

    Merges desired_changes on top of a full copy of snapshot, so the
    returned body always re-asserts every untouched field (especially
    applies_to) instead of omitting it. Also returns wipeRiskFields, the
    list of fields present in snapshot but absent from desired_changes
    that are known to be cleared on omission by this endpoint, purely
    for logging and assertions.
    """
    if "id" not in snapshot:
        raise ValueError("snapshot must include an id")

    body = dict(snapshot)
    body.update(desired_changes)
    body.pop("id", None)

    wipe_risk_fields = [
        field for field in WIPE_RISK_FIELDS
        if field in snapshot and field not in desired_changes
    ]

    return {
        "method": "PUT",
        "path": f"/coupons/{snapshot['id']}",
        "body": body,
        "wipeRiskFields": wipe_risk_fields,
    }


def all_coupons():
    """Page through every coupon in the store."""
    page = 1
    while True:
        coupons = bc_get("/coupons", {"limit": 250, "page": page})
        if not coupons:
            return
        for coupon in coupons:
            yield coupon
        page += 1


def snapshot_coupons():
    return {str(c["id"]): c for c in all_coupons()}


def apply_coupon_update(coupon_id, desired_changes, snapshot_store):
    """Re-fetch fresh, merge with the snapshot, PUT, then verify.

    Returns (after, wiped). If no prior snapshot exists for coupon_id,
    logs a manual-review flag and returns (None, None) without writing.
    """
    key = str(coupon_id)
    if key not in snapshot_store:
        log.warning(
            "coupon_id=%s has no prior snapshot. Flagging for manual review, "
            "not guessing applies_to.",
            coupon_id,
        )
        return None, None

    fresh = bc_get(f"/coupons/{coupon_id}")
    plan = plan_coupon_update(fresh, desired_changes)

    log.info(
        "coupon_id=%s desired_changes=%s wipe_risk_fields=%s (%s)",
        coupon_id, desired_changes, plan["wipeRiskFields"],
        "dry run" if DRY_RUN else "writing",
    )

    if DRY_RUN:
        return fresh, False

    bc_put(plan["path"], plan["body"])

    after = bc_get(f"/coupons/{coupon_id}")
    expected_applies_to = plan["body"].get("applies_to")
    wiped = expected_applies_to is not None and after.get("applies_to") != expected_applies_to

    if wiped:
        corrective_body = dict(after)
        corrective_body["applies_to"] = snapshot_store[key]["applies_to"]
        corrective_body.pop("id", None)
        log.warning(
            "coupon_id=%s wipe detected after write. Corrective applies_to=%s (%s)",
            coupon_id, snapshot_store[key]["applies_to"],
            "dry run, not sent" if DRY_RUN else "sending corrective PUT",
        )
        if not DRY_RUN:
            bc_put(f"/coupons/{coupon_id}", corrective_body)
            after = bc_get(f"/coupons/{coupon_id}")

    return after, wiped


def run():
    snapshot_store = snapshot_coupons()
    log.info("Snapshotted %d coupon(s).", len(snapshot_store))

    wiped_count = 0
    flagged_count = 0

    for coupon_id, coupon in snapshot_store.items():
        desired_changes = {}
        if not desired_changes:
            continue

        after, wiped = apply_coupon_update(coupon_id, desired_changes, snapshot_store)
        if after is None:
            flagged_count += 1
        elif wiped:
            wiped_count += 1

    log.info(
        "Done. %d coupon(s) had a wipe detected and corrected, %d flagged for manual review.",
        wiped_count, flagged_count,
    )


if __name__ == "__main__":
    run()
reconcile-coupon-applies-to.js
/**
 * Edit BigCommerce coupon max_uses without wiping applies_to.
 *
 * The legacy V2 Coupons endpoint (PUT /stores/{store_hash}/v2/coupons/{id})
 * treats PUT as a full-object replace, not a true partial patch, for the
 * applies_to sub-object. BigCommerce's own docs state that if applies_to is
 * not included in the PUT request, its existing value on the coupon will be
 * cleared. A script that PUTs only {"max_uses": 50} to bump a usage cap
 * silently resets applies_to back to its default, wiping the coupon's
 * product or category restriction. The response is still 200 and every
 * other field looks correct, so the loss is silent.
 *
 * This script snapshots every coupon before any write, re-fetches the
 * freshest copy right before each PUT, always composes the PUT body by
 * merging the snapshot into desiredChanges (never a bare partial), and
 * verifies with a follow-up GET that applies_to survived. If a wipe is
 * detected, a corrective PUT resending the snapshotted applies_to is
 * logged (DRY_RUN=true) or sent and re-verified (DRY_RUN=false). Coupons
 * with no prior snapshot are flagged for manual review, never guessed.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/coupon-applies-to-wiped-on-max-uses-edit/
 */
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}/v2`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const WIPE_RISK_FIELDS = ["applies_to"];

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

/**
 * Pure decision. No network, no side effects.
 *
 * Merges desiredChanges on top of a full copy of snapshot, so the
 * returned body always re-asserts every untouched field (especially
 * applies_to) instead of omitting it. Also returns wipeRiskFields, the
 * list of fields present in snapshot but absent from desiredChanges
 * that are known to be cleared on omission by this endpoint, purely
 * for logging and assertions.
 */
export function planCouponUpdate(snapshot, desiredChanges) {
  if (!snapshot || typeof snapshot.id === "undefined") {
    throw new Error("snapshot must include an id");
  }

  const body = { ...snapshot, ...desiredChanges };
  delete body.id;

  const wipeRiskFields = WIPE_RISK_FIELDS.filter(
    (field) => field in snapshot && !(field in desiredChanges)
  );

  return {
    method: "PUT",
    path: `/coupons/${snapshot.id}`,
    body,
    wipeRiskFields,
  };
}

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}`);
  const text = await res.text();
  return text ? JSON.parse(text) : [];
}

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

async function* allCoupons() {
  let page = 1;
  while (true) {
    const coupons = await bcGet("/coupons", { limit: 250, page });
    if (!coupons.length) return;
    for (const coupon of coupons) yield coupon;
    page += 1;
  }
}

async function snapshotCoupons() {
  const snapshot = {};
  for await (const coupon of allCoupons()) snapshot[String(coupon.id)] = coupon;
  return snapshot;
}

async function applyCouponUpdate(couponId, desiredChanges, snapshotStore) {
  const key = String(couponId);
  if (!(key in snapshotStore)) {
    console.warn(
      `coupon_id=${couponId} has no prior snapshot. Flagging for manual review, not guessing applies_to.`
    );
    return { after: null, wiped: null };
  }

  const fresh = await bcGet(`/coupons/${couponId}`);
  const plan = planCouponUpdate(fresh, desiredChanges);

  console.log(
    `coupon_id=${couponId} desired_changes=${JSON.stringify(desiredChanges)} ` +
    `wipe_risk_fields=${JSON.stringify(plan.wipeRiskFields)} (${DRY_RUN ? "dry run" : "writing"})`
  );

  if (DRY_RUN) return { after: fresh, wiped: false };

  await bcPut(plan.path, plan.body);

  let after = await bcGet(`/coupons/${couponId}`);
  const expectedAppliesTo = plan.body.applies_to;
  const wiped = expectedAppliesTo !== undefined &&
    JSON.stringify(after.applies_to) !== JSON.stringify(expectedAppliesTo);

  if (wiped) {
    const correctiveBody = { ...after, applies_to: snapshotStore[key].applies_to };
    delete correctiveBody.id;
    console.warn(
      `coupon_id=${couponId} wipe detected after write. Corrective applies_to=` +
      `${JSON.stringify(snapshotStore[key].applies_to)} ` +
      `(${DRY_RUN ? "dry run, not sent" : "sending corrective PUT"})`
    );
    if (!DRY_RUN) {
      await bcPut(`/coupons/${couponId}`, correctiveBody);
      after = await bcGet(`/coupons/${couponId}`);
    }
  }

  return { after, wiped };
}

export async function run() {
  const snapshotStore = await snapshotCoupons();
  console.log(`Snapshotted ${Object.keys(snapshotStore).length} coupon(s).`);

  let wipedCount = 0;
  let flaggedCount = 0;

  for (const [couponId, coupon] of Object.entries(snapshotStore)) {
    const desiredChanges = {};
    if (Object.keys(desiredChanges).length === 0) continue;

    const { after, wiped } = await applyCouponUpdate(couponId, desiredChanges, snapshotStore);
    if (after === null) flaggedCount += 1;
    else if (wiped) wipedCount += 1;
  }

  console.log(
    `Done. ${wipedCount} coupon(s) had a wipe detected and corrected, ${flaggedCount} flagged for manual review.`
  );
}

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

Add a test

The planning function is the part most worth testing, because it decides exactly what gets sent to an endpoint that will happily clear a field you forget. Because plan_coupon_update takes only plain values and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in fixture snapshots and checks the composed body.

test_coupon_applies_to_plan.py
from reconcile_coupon_applies_to import plan_coupon_update


def fixture_snapshot(**overrides):
    base = {
        "id": 1,
        "code": "SAVE10",
        "type": "percentage_discount",
        "amount": "10.0000000000",
        "max_uses": 100,
        "num_uses": 42,
        "applies_to": {"entity": "products", "ids": [123, 456]},
    }
    base.update(overrides)
    return base


def test_merges_desired_changes_onto_full_snapshot():
    plan = plan_coupon_update(fixture_snapshot(), {"max_uses": 50})
    assert plan["method"] == "PUT"
    assert plan["path"] == "/coupons/1"
    assert plan["body"]["max_uses"] == 50


def test_body_always_reasserts_applies_to_when_not_in_desired_changes():
    plan = plan_coupon_update(fixture_snapshot(), {"max_uses": 50})
    assert plan["body"]["applies_to"] == {"entity": "products", "ids": [123, 456]}


def test_body_never_omits_untouched_fields():
    plan = plan_coupon_update(fixture_snapshot(), {"max_uses": 50})
    assert plan["body"]["code"] == "SAVE10"
    assert plan["body"]["num_uses"] == 42


def test_wipe_risk_fields_flags_applies_to_when_omitted():
    plan = plan_coupon_update(fixture_snapshot(), {"max_uses": 50})
    assert plan["wipeRiskFields"] == ["applies_to"]


def test_wipe_risk_fields_empty_when_applies_to_is_the_intended_change():
    new_applies_to = {"entity": "categories", "ids": [9]}
    plan = plan_coupon_update(fixture_snapshot(), {"applies_to": new_applies_to})
    assert plan["wipeRiskFields"] == []
    assert plan["body"]["applies_to"] == new_applies_to


def test_body_never_includes_the_id_field():
    plan = plan_coupon_update(fixture_snapshot(), {"max_uses": 50})
    assert "id" not in plan["body"]
reconcile-coupon-applies-to.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { planCouponUpdate } from "./reconcile-coupon-applies-to.js";

const fixtureSnapshot = (overrides = {}) => ({
  id: 1,
  code: "SAVE10",
  type: "percentage_discount",
  amount: "10.0000000000",
  max_uses: 100,
  num_uses: 42,
  applies_to: { entity: "products", ids: [123, 456] },
  ...overrides,
});

test("merges desired changes onto a full snapshot", () => {
  const plan = planCouponUpdate(fixtureSnapshot(), { max_uses: 50 });
  assert.equal(plan.method, "PUT");
  assert.equal(plan.path, "/coupons/1");
  assert.equal(plan.body.max_uses, 50);
});

test("body always reasserts applies_to when not in desired changes", () => {
  const plan = planCouponUpdate(fixtureSnapshot(), { max_uses: 50 });
  assert.deepEqual(plan.body.applies_to, { entity: "products", ids: [123, 456] });
});

test("body never omits untouched fields", () => {
  const plan = planCouponUpdate(fixtureSnapshot(), { max_uses: 50 });
  assert.equal(plan.body.code, "SAVE10");
  assert.equal(plan.body.num_uses, 42);
});

test("wipeRiskFields flags applies_to when omitted", () => {
  const plan = planCouponUpdate(fixtureSnapshot(), { max_uses: 50 });
  assert.deepEqual(plan.wipeRiskFields, ["applies_to"]);
});

test("wipeRiskFields is empty when applies_to is the intended change", () => {
  const newAppliesTo = { entity: "categories", ids: [9] };
  const plan = planCouponUpdate(fixtureSnapshot(), { applies_to: newAppliesTo });
  assert.deepEqual(plan.wipeRiskFields, []);
  assert.deepEqual(plan.body.applies_to, newAppliesTo);
});

test("body never includes the id field", () => {
  const plan = planCouponUpdate(fixtureSnapshot(), { max_uses: 50 });
  assert.equal("id" in plan.body, false);
});

Case studies

Popularity cap raise

The flash sale coupon that started applying to everything

A merchant ran a coupon restricted to a specific clearance category with a max_uses of 100. It sold out in a morning, so a script bumped max_uses to 500 with a single-field PUT. The response was 200, max_uses read 500, everyone moved on. Two days later the same code was showing up on full-price items across the catalog.

The reconciler pattern catches this before it ships: composing the PUT from a full fresh snapshot means applies_to is resent every time, so raising a cap can never again quietly remove a restriction.

Bulk max_uses sync

The nightly job that reset every coupon's per-customer limit

A nightly sync updated max_uses_per_customer on a batch of seasonal coupons from a spreadsheet, sending a minimal body for each one. Support started getting tickets from customers whose favorite category-restricted discount code suddenly worked on unrelated products, and no one connected it back to a routine nightly job.

Once the reconciler snapshot store was in place, the post-write diff step immediately flagged every coupon in that batch, all traced back to the same omitted applies_to field, and the corrective PUT step restored every one from its pre-sync snapshot.

What good looks like

After this reconciler is in place, no edit to max_uses, or any other single coupon field, can silently remove a product or category restriction. Every write is composed from a fresh full snapshot, every write is verified with a follow-up GET, and any coupon with no prior snapshot is flagged for a human rather than guessed at.

FAQ

Why does editing max_uses on a BigCommerce coupon remove its product restriction?

The legacy V2 Coupons endpoint treats PUT as a full-object replace, not a true partial patch, for the applies_to sub-object. BigCommerce's own documentation states that if applies_to is not included in the PUT request, its existing value on the coupon is cleared. A script that sends only {"max_uses": 50} silently resets applies_to to its default, which removes the product or category restriction while every other field appears unchanged.

How do I know if a coupon update already wiped applies_to?

Snapshot every coupon's id, code, type, amount, max_uses, num_uses, and full applies_to object before any write. After each PUT, re-GET the coupon and diff applies_to.ids and applies_to.entity against the pre-write snapshot. If ids is now empty or missing, or entity changed without that being an intended part of the request, the update wiped the restriction.

Is it safe to auto-repair a coupon once a wipe is detected?

Only if you have a trustworthy pre-change snapshot of that coupon's applies_to. Never guess or fabricate a restriction. Run the corrective PUT behind a DRY_RUN flag so you can review the intended payload first, and if no prior snapshot exists for a coupon, flag it for manual review instead of auto-repairing it.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: the V2 Coupons resource and the applies_to field. developer.bigcommerce.com coupons
  2. BigCommerce Docs: the coupon object schema, applies_to, max_uses, num_uses. docs.bigcommerce.com create coupon
  3. Community-maintained V2 reference documenting applies_to semantics. github.com bigcommerce-api-docs coupons.md

On the solution:

  1. BigCommerce API Reference: Update a Coupon (Management APIs). developer.bigcommerce.com update a coupon
  2. BigCommerce Docs: List Coupons. docs.bigcommerce.com get coupons
  3. BigCommerce Developer Center: the Promotions API as the modern replacement model. developer.bigcommerce.com promotions

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, coupons, 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 save a coupon from going store-wide?

If this saved you a support ticket storm or caught a wipe 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