Diagnostic Coupons / Promotions

A parent promotion's max_uses overrides a coupon's own usage cap

A coupon code still looks available. Its own max_uses has plenty of headroom left. But the shopper types it in at checkout and gets invalid coupon code anyway. Nothing about the coupon record itself changed. The parent Promotion it lives under has its own, separate max_uses cap, and BigCommerce checks that cap first. Here is why the promotion-level limit silently overrides a coupon that looks fine in isolation, and a small script that flags every code this is happening to.

Python and Node.js BigCommerce Promotions v3 API Safe by default (flag only)
A grocery store filled with lots of bags of food
Photo by Bernd 📷 Dittrich on Unsplash
The short answer

In BigCommerce's Promotions v3 model, a coupon code is a child resource nested under a parent Promotion at /v3/promotions/{promotionId}/codes/{codeId}, and both levels carry independent max_uses and current_uses counters. BigCommerce enforces both at checkout, and the promotion-level cap is the outer gate: once the parent promotion's current_uses reaches its max_uses (or its status flips away from ENABLED as a result), every child coupon code under it is rejected as invalid, no matter how much headroom that code's own counter still shows. Run a small Python or Node.js script that pages every ENABLED promotion, pages its codes, and flags any code where the promotion is already exhausted or where the code's own remaining uses are larger than what the promotion has left, meaning it will look available to a merchant but get rejected first. Full code, tests, and the pure decision function are below.

The problem in plain words

BigCommerce's Promotions v3 API splits a coupon promotion into two separate records. The Promotion itself, at /v3/promotions/{promotionId}, has a max_uses and a current_uses, a status, and a redemption_type that tells you whether it is coupon-based. Underneath it, each individual coupon code lives at /v3/promotions/{promotionId}/codes/{codeId}, and it has its own max_uses, current_uses, and even a max_uses_per_customer.

Both counters are real and both are enforced, but they are not enforced as equals. BigCommerce checks the promotion's own cap first. If the promotion's aggregate current_uses has already reached its max_uses, every coupon code nested underneath it is dead on arrival, including a code whose own max_uses was set generously and whose own current_uses is nowhere close to it. The code record looks perfectly healthy. The shopper still gets invalid coupon code. A merchant who edits the coupon's own max_uses without checking the parent promotion's smaller or already-reached cap ends up filing a support ticket about a coupon that "should still work," when the coupon was never really the constraint.

Coupon code max_uses 500, current_uses 40 Parent promotion max_uses 50, current_uses 50 Outer gate first Checkout enforcement promotion cap checked first Invalid coupon code shopper is rejected Code looks fine in isolation, promotion is exhausted underneath it
The coupon code's own counter still has room. The parent promotion's counter does not, and BigCommerce checks the promotion first.

Why it happens

The Promotions v3 model was built so a single promotion can hand out many coupon codes, for example one code per affiliate or one per email batch, while still capping the total redemptions across all of them. A few ways this cap ends up silently overriding a coupon that looks available:

The frustrating part for a merchant is that nothing about the coupon code record itself signals the real constraint. You have to look up one level, at the parent promotion, to see the number that actually matters. See the citations at the end for the exact docs and support threads.

The key insight

A coupon code's own max_uses is not the ceiling. The parent promotion's max_uses is, because BigCommerce's documentation is explicit that a code-level limit cannot exceed the promotion-level limit. So the safe pattern is not "check whether this coupon still has uses left." It is "check whether the promotion it belongs to still has uses left, and treat any code whose own remaining uses are larger than the promotion's remaining uses as a code that will look available but get rejected first."

The fix, as a flow

We do not touch checkout enforcement or any merchant's deliberate promotion cap. We add a job that walks every ENABLED promotion, pages its child codes, and classifies each code against both counters, so a merchant can see exactly which codes are silently gated by their parent before a shopper ever finds out the hard way.

List ENABLED promotions (v3) Page promotion codes /codes for each promotion Compute remaining promo vs code uses left Promo exhausted or code remaining > promo? yes Flag: capped out reported, not written no ok no gating risk
The job never writes to a promotion's cap on its own. It reports which codes are already blocked, or about to be, so a merchant can decide whether to raise the cap.

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) or at least read scope so it can read promotions and coupon codes. 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 only if you opt into --apply
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 only if you opt into --apply
2

Talk to the V3 Promotions 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 results in {data, meta.pagination}, so a helper needs to walk meta.pagination to page through every promotion and every code.

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_page(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    body = r.json()
    return body.get("data", []), body.get("meta", {}).get("pagination", {})

def bc_get_all(path, params=None):
    page = 1
    items = []
    while True:
        data, pagination = bc_get_page(path, {**(params or {}), "page": page, "limit": 250})
        items.extend(data)
        total_pages = pagination.get("total_pages", 1)
        if page >= total_pages:
            return items
        page += 1
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 bcGetPage(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 body = await res.json();
  return [body.data || [], (body.meta || {}).pagination || {}];
}

async function bcGetAll(path, params = {}) {
  let page = 1;
  const items = [];
  while (true) {
    const [data, pagination] = await bcGetPage(path, { ...params, page, limit: 250 });
    items.push(...data);
    const totalPages = pagination.total_pages || 1;
    if (page >= totalPages) return items;
    page += 1;
  }
}
3

List ENABLED promotions and their coupon codes

Call GET /v3/promotions?status=ENABLED, paginated, to pull each promotion's {id, name, max_uses, current_uses, status, redemption_type}. For each promotion whose redemption_type contains COUPON, call GET /v3/promotions/{promotionId}/codes, paginated, to pull each code's {id, code, max_uses, current_uses, max_uses_per_customer}.

step3.py
def enabled_coupon_promotions():
    promotions = bc_get_all("/promotions", {"status": "ENABLED"})
    return [p for p in promotions if "COUPON" in (p.get("redemption_type") or "")]

def promotion_codes(promotion_id):
    return bc_get_all(f"/promotions/{promotion_id}/codes")
step3.js
async function enabledCouponPromotions() {
  const promotions = await bcGetAll("/promotions", { status: "ENABLED" });
  return promotions.filter((p) => (p.redemption_type || "").includes("COUPON"));
}

async function promotionCodes(promotionId) {
  return bcGetAll(`/promotions/${promotionId}/codes`);
}
4

Decide, with one pure function

Keep the classification in its own function that takes a promotion and its list of codes, and returns each code annotated with a reason. Treat max_uses == 0 as unlimited, matching how BigCommerce itself treats zero on this field. A code is only safe when the promotion has room left and the code's own remaining uses do not exceed what the promotion has left.

decide.py
def find_capped_out_codes(promotion: dict, codes: list) -> list:
    promo_max = promotion.get("max_uses", 0) or 0
    promo_current = promotion.get("current_uses", 0) or 0
    promo_remaining = None if promo_max == 0 else max(promo_max - promo_current, 0)

    results = []
    for code in codes:
        code_max = code.get("max_uses", 0) or 0
        code_current = code.get("current_uses", 0) or 0
        code_remaining = None if code_max == 0 else max(code_max - code_current, 0)

        if promo_remaining == 0:
            reason = "promotion_exhausted"
        elif promo_remaining is not None and (
            code_remaining is None or code_remaining > promo_remaining
        ):
            reason = "promotion_cap_lower_than_code"
        else:
            reason = "ok"

        results.append({
            "code_id": code.get("id"),
            "code": code.get("code"),
            "reason": reason,
            "promotion_remaining": promo_remaining,
            "code_remaining": code_remaining,
        })
    return results
decide.js
export function findCappedOutCodes(promotion, codes) {
  const promoMax = promotion.max_uses || 0;
  const promoCurrent = promotion.current_uses || 0;
  const promoRemaining = promoMax === 0 ? null : Math.max(promoMax - promoCurrent, 0);

  return codes.map((code) => {
    const codeMax = code.max_uses || 0;
    const codeCurrent = code.current_uses || 0;
    const codeRemaining = codeMax === 0 ? null : Math.max(codeMax - codeCurrent, 0);

    let reason;
    if (promoRemaining === 0) {
      reason = "promotion_exhausted";
    } else if (
      promoRemaining !== null &&
      (codeRemaining === null || codeRemaining > promoRemaining)
    ) {
      reason = "promotion_cap_lower_than_code";
    } else {
      reason = "ok";
    }

    return {
      code_id: code.id,
      code: code.code,
      reason,
      promotion_remaining: promoRemaining,
      code_remaining: codeRemaining,
    };
  });
}
5

Report, never auto-fix, unless explicitly opted in

By default the job only prints a report of every code with reason promotion_exhausted or promotion_cap_lower_than_code. Raising a merchant's deliberate cap is a business decision, not a bug, so there is no silent write. If a merchant explicitly wants the safe corrective action, the only supported one is PUT /v3/promotions/{promotionId} raising max_uses (or setting it to 0 for unlimited), and it is gated behind an explicit --apply flag with DRY_RUN=false, always printed as a proposed diff first.

apply.py
def propose_raised_cap(promotion, target_max_uses):
    return {
        "promotion_id": promotion["id"],
        "from_max_uses": promotion.get("max_uses", 0),
        "to_max_uses": target_max_uses,
    }

def apply_raised_cap(promotion_id, target_max_uses):
    return bc_put(f"/promotions/{promotion_id}", {"max_uses": target_max_uses})
apply.js
function proposeRaisedCap(promotion, targetMaxUses) {
  return {
    promotion_id: promotion.id,
    from_max_uses: promotion.max_uses || 0,
    to_max_uses: targetMaxUses,
  };
}

async function applyRaisedCap(promotionId, targetMaxUses) {
  return bcPut(`/promotions/${promotionId}`, { max_uses: targetMaxUses });
}
6

Wire it together with a dry run guard

The loop lists every ENABLED coupon promotion, pages its codes, runs find_capped_out_codes, and logs every code that is not ok. Notice the dry run guard. DRY_RUN defaults to true and there is no default write path at all, only an opt-in --apply flag that must also be paired with DRY_RUN=false before apply_raised_cap is ever called, and even then it prints the proposed diff first.

Run it safe

Never raise or remove a promotion's max_uses automatically. A lower promotion-level cap is very often deliberate, a budget limit, a fraud guard, or a seasonal ceiling, so the default and recommended behavior is to flag and report. Only apply a change when a human has reviewed the printed diff and explicitly opted in with --apply.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every ENABLED coupon promotion and its codes, reports every code silently gated by its parent's cap, and only ever writes to a promotion's max_uses behind an explicit --apply flag with DRY_RUN=false, printing the proposed change first.

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

find_capped_promotion_codes.py
"""Find BigCommerce coupon codes silently gated by their parent promotion's cap.

In the Promotions v3 model, a coupon code is a child resource nested under a
parent Promotion (/v3/promotions/{promotionId}/codes/{codeId}), and both levels
carry independent max_uses/current_uses counters. BigCommerce enforces both at
checkout, and the promotion-level cap is the outer gate: even if a code's own
max_uses has plenty of headroom, the shopper gets "invalid coupon code" once the
parent promotion's aggregate current_uses reaches its max_uses. This job lists
every ENABLED promotion, pages its coupon codes, and flags any code where the
promotion is already exhausted or where the code's own remaining uses exceed
what the promotion has left. It never writes to a promotion's cap by default.
Raising a merchant's deliberate cap is a business decision, so a write only
happens behind an explicit --apply flag with DRY_RUN=false, and it always
prints the proposed diff first.

Guide: https://www.allanninal.dev/bigcommerce/parent-promotion-caps-override-coupon/
"""
import os
import sys
import logging

import requests

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

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"
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_page(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    body = r.json()
    return body.get("data", []), body.get("meta", {}).get("pagination", {})


def bc_get_all(path, params=None):
    page = 1
    items = []
    while True:
        data, pagination = bc_get_page(path, {**(params or {}), "page": page, "limit": 250})
        items.extend(data)
        total_pages = pagination.get("total_pages", 1)
        if page >= total_pages:
            return items
        page += 1


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 find_capped_out_codes(promotion: dict, codes: list) -> list:
    """Pure decision. No network, no side effects.

    promotion={id,max_uses,current_uses,status}
    codes=[{id,code,max_uses,current_uses}]

    promo_remaining = None if promotion["max_uses"] == 0 else
        max(promotion["max_uses"] - promotion["current_uses"], 0)
    code_remaining = None if code["max_uses"] == 0 else
        max(code["max_uses"] - code["current_uses"], 0)

    reason is "promotion_exhausted" if promo_remaining == 0,
    "promotion_cap_lower_than_code" if promo_remaining is not None and
        (code_remaining is None or code_remaining > promo_remaining),
    otherwise "ok".
    """
    promo_max = promotion.get("max_uses", 0) or 0
    promo_current = promotion.get("current_uses", 0) or 0
    promo_remaining = None if promo_max == 0 else max(promo_max - promo_current, 0)

    results = []
    for code in codes:
        code_max = code.get("max_uses", 0) or 0
        code_current = code.get("current_uses", 0) or 0
        code_remaining = None if code_max == 0 else max(code_max - code_current, 0)

        if promo_remaining == 0:
            reason = "promotion_exhausted"
        elif promo_remaining is not None and (
            code_remaining is None or code_remaining > promo_remaining
        ):
            reason = "promotion_cap_lower_than_code"
        else:
            reason = "ok"

        results.append({
            "code_id": code.get("id"),
            "code": code.get("code"),
            "reason": reason,
            "promotion_remaining": promo_remaining,
            "code_remaining": code_remaining,
        })
    return results


def enabled_coupon_promotions():
    promotions = bc_get_all("/promotions", {"status": "ENABLED"})
    return [p for p in promotions if "COUPON" in (p.get("redemption_type") or "")]


def promotion_codes(promotion_id):
    return bc_get_all(f"/promotions/{promotion_id}/codes")


def propose_raised_cap(promotion, target_max_uses):
    return {
        "promotion_id": promotion["id"],
        "from_max_uses": promotion.get("max_uses", 0),
        "to_max_uses": target_max_uses,
    }


def apply_raised_cap(promotion_id, target_max_uses):
    return bc_put(f"/promotions/{promotion_id}", {"max_uses": target_max_uses})


def run(apply_fix=False):
    flagged = 0
    checked = 0

    for promotion in enabled_coupon_promotions():
        codes = promotion_codes(promotion["id"])
        annotated = find_capped_out_codes(promotion, codes)
        checked += len(annotated)

        problem_codes = [c for c in annotated if c["reason"] != "ok"]
        if not problem_codes:
            continue

        max_code_max_uses = max((c.get("max_uses", 0) or 0) for c in codes) if codes else 0
        target_max_uses = max(max_code_max_uses, promotion.get("max_uses", 0) or 0)

        for c in problem_codes:
            log.warning(
                "promotion_id=%s promotion_name=%s code_id=%s code=%s reason=%s "
                "promotion_remaining=%s code_remaining=%s",
                promotion["id"], promotion.get("name"), c["code_id"], c["code"],
                c["reason"], c["promotion_remaining"], c["code_remaining"],
            )
            flagged += 1

        if apply_fix and target_max_uses != (promotion.get("max_uses", 0) or 0):
            diff = propose_raised_cap(promotion, target_max_uses)
            log.info("Proposed fix: %s (%s)", diff, "dry run" if DRY_RUN else "applying")
            if not DRY_RUN:
                apply_raised_cap(promotion["id"], target_max_uses)

    log.info("Done. %d code(s) checked, %d code(s) flagged.", checked, flagged)


if __name__ == "__main__":
    run(apply_fix="--apply" in sys.argv)
find-capped-promotion-codes.js
/**
 * Find BigCommerce coupon codes silently gated by their parent promotion's cap.
 *
 * In the Promotions v3 model, a coupon code is a child resource nested under a
 * parent Promotion (/v3/promotions/{promotionId}/codes/{codeId}), and both levels
 * carry independent max_uses/current_uses counters. BigCommerce enforces both at
 * checkout, and the promotion-level cap is the outer gate: even if a code's own
 * max_uses has plenty of headroom, the shopper gets "invalid coupon code" once the
 * parent promotion's aggregate current_uses reaches its max_uses. This job lists
 * every ENABLED promotion, pages its coupon codes, and flags any code where the
 * promotion is already exhausted or where the code's own remaining uses exceed
 * what the promotion has left. It never writes to a promotion's cap by default.
 * Raising a merchant's deliberate cap is a business decision, so a write only
 * happens behind an explicit --apply flag with DRY_RUN=false, and it always
 * prints the proposed diff first.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/parent-promotion-caps-override-coupon/
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

/**
 * Pure decision. No network, no side effects.
 *
 * promotion={id,max_uses,current_uses,status}
 * codes=[{id,code,max_uses,current_uses}]
 *
 * promoRemaining = null if promotion.max_uses === 0 else
 *   max(promotion.max_uses - promotion.current_uses, 0)
 * codeRemaining = null if code.max_uses === 0 else
 *   max(code.max_uses - code.current_uses, 0)
 *
 * reason is "promotion_exhausted" if promoRemaining === 0,
 * "promotion_cap_lower_than_code" if promoRemaining !== null and
 *   (codeRemaining === null or codeRemaining > promoRemaining),
 * otherwise "ok".
 */
export function findCappedOutCodes(promotion, codes) {
  const promoMax = promotion.max_uses || 0;
  const promoCurrent = promotion.current_uses || 0;
  const promoRemaining = promoMax === 0 ? null : Math.max(promoMax - promoCurrent, 0);

  return codes.map((code) => {
    const codeMax = code.max_uses || 0;
    const codeCurrent = code.current_uses || 0;
    const codeRemaining = codeMax === 0 ? null : Math.max(codeMax - codeCurrent, 0);

    let reason;
    if (promoRemaining === 0) {
      reason = "promotion_exhausted";
    } else if (
      promoRemaining !== null &&
      (codeRemaining === null || codeRemaining > promoRemaining)
    ) {
      reason = "promotion_cap_lower_than_code";
    } else {
      reason = "ok";
    }

    return {
      code_id: code.id,
      code: code.code,
      reason,
      promotion_remaining: promoRemaining,
      code_remaining: codeRemaining,
    };
  });
}

async function bcGetPage(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 body = await res.json();
  return [body.data || [], (body.meta || {}).pagination || {}];
}

async function bcGetAll(path, params = {}) {
  let page = 1;
  const items = [];
  while (true) {
    const [data, pagination] = await bcGetPage(path, { ...params, page, limit: 250 });
    items.push(...data);
    const totalPages = pagination.total_pages || 1;
    if (page >= totalPages) return items;
    page += 1;
  }
}

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 enabledCouponPromotions() {
  const promotions = await bcGetAll("/promotions", { status: "ENABLED" });
  return promotions.filter((p) => (p.redemption_type || "").includes("COUPON"));
}

async function promotionCodes(promotionId) {
  return bcGetAll(`/promotions/${promotionId}/codes`);
}

function proposeRaisedCap(promotion, targetMaxUses) {
  return {
    promotion_id: promotion.id,
    from_max_uses: promotion.max_uses || 0,
    to_max_uses: targetMaxUses,
  };
}

async function applyRaisedCap(promotionId, targetMaxUses) {
  return bcPut(`/promotions/${promotionId}`, { max_uses: targetMaxUses });
}

export async function run(applyFix = false) {
  let flagged = 0;
  let checked = 0;

  for (const promotion of await enabledCouponPromotions()) {
    const codes = await promotionCodes(promotion.id);
    const annotated = findCappedOutCodes(promotion, codes);
    checked += annotated.length;

    const problemCodes = annotated.filter((c) => c.reason !== "ok");
    if (!problemCodes.length) continue;

    const maxCodeMaxUses = codes.length ? Math.max(...codes.map((c) => c.max_uses || 0)) : 0;
    const targetMaxUses = Math.max(maxCodeMaxUses, promotion.max_uses || 0);

    for (const c of problemCodes) {
      console.warn(
        `promotion_id=${promotion.id} promotion_name=${promotion.name} code_id=${c.code_id} ` +
        `code=${c.code} reason=${c.reason} promotion_remaining=${c.promotion_remaining} ` +
        `code_remaining=${c.code_remaining}`
      );
      flagged += 1;
    }

    if (applyFix && targetMaxUses !== (promotion.max_uses || 0)) {
      const diff = proposeRaisedCap(promotion, targetMaxUses);
      console.log(`Proposed fix: ${JSON.stringify(diff)} (${DRY_RUN ? "dry run" : "applying"})`);
      if (!DRY_RUN) await applyRaisedCap(promotion.id, targetMaxUses);
    }
  }

  console.log(`Done. ${checked} code(s) checked, ${flagged} code(s) flagged.`);
}

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

Add a test

The classification rule is the part most worth testing, because it decides which codes get reported as gated by their parent. Because find_capped_out_codes takes only plain dicts and ints and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_parent_cap_reason.py
from find_capped_promotion_codes import find_capped_out_codes


def promotion(max_uses=50, current_uses=0, status="ENABLED"):
    return {"id": 1, "max_uses": max_uses, "current_uses": current_uses, "status": status}


def code(id_=1, code="SAVE10", max_uses=500, current_uses=0):
    return {"id": id_, "code": code, "max_uses": max_uses, "current_uses": current_uses}


def test_ok_when_promotion_has_more_room_than_code():
    promo = promotion(max_uses=500, current_uses=10)
    result = find_capped_out_codes(promo, [code(max_uses=50, current_uses=5)])
    assert result[0]["reason"] == "ok"


def test_promotion_exhausted_when_current_uses_reaches_max():
    promo = promotion(max_uses=50, current_uses=50)
    result = find_capped_out_codes(promo, [code(max_uses=500, current_uses=40)])
    assert result[0]["reason"] == "promotion_exhausted"
    assert result[0]["promotion_remaining"] == 0


def test_promotion_cap_lower_than_code_when_code_remaining_exceeds_promotion():
    promo = promotion(max_uses=50, current_uses=40)
    result = find_capped_out_codes(promo, [code(max_uses=500, current_uses=40)])
    assert result[0]["reason"] == "promotion_cap_lower_than_code"
    assert result[0]["promotion_remaining"] == 10
    assert result[0]["code_remaining"] == 460


def test_unlimited_code_flagged_when_promotion_is_capped():
    promo = promotion(max_uses=50, current_uses=10)
    result = find_capped_out_codes(promo, [code(max_uses=0, current_uses=0)])
    assert result[0]["reason"] == "promotion_cap_lower_than_code"
    assert result[0]["code_remaining"] is None


def test_unlimited_promotion_never_gates_a_code():
    promo = promotion(max_uses=0, current_uses=999)
    result = find_capped_out_codes(promo, [code(max_uses=500, current_uses=0)])
    assert result[0]["reason"] == "ok"
    assert result[0]["promotion_remaining"] is None


def test_multiple_codes_are_each_classified_independently():
    promo = promotion(max_uses=50, current_uses=45)
    codes = [code(id_=1, max_uses=10, current_uses=8), code(id_=2, max_uses=500, current_uses=0)]
    result = find_capped_out_codes(promo, codes)
    by_id = {r["code_id"]: r["reason"] for r in result}
    assert by_id[1] == "ok"
    assert by_id[2] == "promotion_cap_lower_than_code"
find-capped-promotion-codes.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findCappedOutCodes } from "./find-capped-promotion-codes.js";

const promotion = ({ max_uses = 50, current_uses = 0, status = "ENABLED" } = {}) => ({
  id: 1, max_uses, current_uses, status,
});

const code = ({ id = 1, code: c = "SAVE10", max_uses = 500, current_uses = 0 } = {}) => ({
  id, code: c, max_uses, current_uses,
});

test("ok when promotion has more room than code", () => {
  const promo = promotion({ max_uses: 500, current_uses: 10 });
  const result = findCappedOutCodes(promo, [code({ max_uses: 50, current_uses: 5 })]);
  assert.equal(result[0].reason, "ok");
});

test("promotion_exhausted when current_uses reaches max", () => {
  const promo = promotion({ max_uses: 50, current_uses: 50 });
  const result = findCappedOutCodes(promo, [code({ max_uses: 500, current_uses: 40 })]);
  assert.equal(result[0].reason, "promotion_exhausted");
  assert.equal(result[0].promotion_remaining, 0);
});

test("promotion_cap_lower_than_code when code remaining exceeds promotion", () => {
  const promo = promotion({ max_uses: 50, current_uses: 40 });
  const result = findCappedOutCodes(promo, [code({ max_uses: 500, current_uses: 40 })]);
  assert.equal(result[0].reason, "promotion_cap_lower_than_code");
  assert.equal(result[0].promotion_remaining, 10);
  assert.equal(result[0].code_remaining, 460);
});

test("unlimited code is flagged when promotion is capped", () => {
  const promo = promotion({ max_uses: 50, current_uses: 10 });
  const result = findCappedOutCodes(promo, [code({ max_uses: 0, current_uses: 0 })]);
  assert.equal(result[0].reason, "promotion_cap_lower_than_code");
  assert.equal(result[0].code_remaining, null);
});

test("unlimited promotion never gates a code", () => {
  const promo = promotion({ max_uses: 0, current_uses: 999 });
  const result = findCappedOutCodes(promo, [code({ max_uses: 500, current_uses: 0 })]);
  assert.equal(result[0].reason, "ok");
  assert.equal(result[0].promotion_remaining, null);
});

test("multiple codes are each classified independently", () => {
  const promo = promotion({ max_uses: 50, current_uses: 45 });
  const codes = [code({ id: 1, max_uses: 10, current_uses: 8 }), code({ id: 2, max_uses: 500, current_uses: 0 })];
  const result = findCappedOutCodes(promo, codes);
  const byId = Object.fromEntries(result.map((r) => [r.code_id, r.reason]));
  assert.equal(byId[1], "ok");
  assert.equal(byId[2], "promotion_cap_lower_than_code");
});

Case studies

Affiliate codes

The store with one code per affiliate under a shared budget cap

A merchant ran a promotion with a separate coupon code for each of a dozen affiliates, each code individually capped at 500 uses so no single affiliate could dominate. The promotion itself carried a shared max_uses of 300, meant to cap the total marketing spend across every affiliate combined.

Two months in, support tickets started arriving about codes that "should still work." The reconciler job flagged every one of the twelve codes as promotion_exhausted the moment the shared 300 was reached, even though most individual codes were nowhere near their own 500. The merchant now knows to look at the promotion's own cap first, and raises it deliberately, with a printed diff, when the budget changes.

Reused promotion

The seasonal promotion that got a bigger code added later

A small test promotion was created with a max_uses of 25 to trial a discount with a handful of newsletter subscribers. Later, a new code was added under the same promotion for a much larger campaign, with its own max_uses set to 2,000, but nobody revisited the parent promotion's original 25.

The new campaign code was rejected as invalid almost immediately, confusing the marketing team who could see thousands of remaining uses on the code itself. The job's report pointed straight at the parent promotion's stale max_uses of 25 as the real constraint, letting the team fix the actual number instead of debugging the coupon record.

What good looks like

After this runs on a schedule, no merchant discovers a promotion-level cap the hard way, through a shopper's failed checkout. Every coupon code that is silently gated by its parent's max_uses, whether the promotion is already exhausted or just has less headroom than the code implies, shows up in a report with both counters side by side. Nothing about a merchant's deliberate cap gets changed unless they explicitly opt in and review the diff first.

FAQ

Why does my BigCommerce coupon say invalid coupon code when it still has uses left?

A coupon code in BigCommerce's Promotions v3 model is a child resource nested under a parent Promotion, and both levels carry their own max_uses and current_uses counters. BigCommerce enforces both at checkout. Even if the code's own max_uses has headroom, the shopper is rejected once the parent promotion's current_uses reaches its max_uses, because the promotion-level cap is the outer gate and cannot be exceeded by any child code.

Can a coupon code's max_uses be higher than its parent promotion's max_uses?

BigCommerce's own merchant documentation states the code-level limit cannot exceed the promotion-level limit. You can set a code's max_uses higher in the record, but it has no practical effect once the promotion's own cap is lower or already reached, because the promotion is checked first and gates every child code underneath it.

Is it safe to auto-raise a promotion's max_uses to fix this?

No. Raising or removing a merchant's deliberate promotion-level cap is a business decision, not a technical bug, so the default behavior should only flag and report which codes are affected. If a merchant explicitly opts in, the only safe corrective action is a dry-run-guarded PUT to the promotion that raises max_uses, always shown as a proposed diff first and gated behind an explicit apply flag.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: the Promotions API overview, promotion and code resources. developer.bigcommerce.com promotions
  2. BigCommerce Support: Automatic and Coupon Promotions (Standard Editor). support.bigcommerce.com automatic and coupon promotions
  3. BigCommerce Community: promotion with coupon not working. support.bigcommerce.com promotion with coupon not working

On the solution:

  1. BigCommerce Developer Center: the Coupon Codes Single endpoint. developer.bigcommerce.com coupon codes single
  2. BigCommerce Developer Center: the Promotions Single endpoint. developer.bigcommerce.com promotions single
  3. BigCommerce Developer Center: the Coupon Codes Bulk endpoint. developer.bigcommerce.com coupon codes bulk

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, promotions, 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 untangle a promotion cap mystery?

If this saved you from chasing a coupon record that was never the real problem, 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