Reconciler Promotions and coupons

Coupon usage miscounts in BigCommerce

A coupon that should still have plenty of uses left suddenly tells a legitimate customer it is maxed out. Nothing looks wrong in the order list. But BigCommerce has been counting every order that ever carried the code, including the ones that were cancelled, declined, refunded, or deleted, and it never gives those uses back. Here is why the count drifts upward and a small script that reconciles it against real completed orders and flags the difference for review.

Python and Node.js V2 Coupons and Orders API Safe by default (dry run, flag only)
The short answer

BigCommerce's V2 coupon object tracks num_uses as a read-only, system-maintained counter. It goes up the moment an order is placed with the code applied, on /v2/coupons, but nothing ever subtracts from it when that order is cancelled, declined, refunded, or manually edited or deleted in the Admin. Run a small Python or Node.js script that pages GET /v2/coupons for each coupon's reported num_uses, pages GET /v2/orders plus GET /v2/orders/{id}/coupons to see which orders really carried that code, counts only the orders whose status_id represents a genuine completed or in-progress sale, and compares the two numbers. It never writes to num_uses, because that field cannot be corrected through the documented API. It only produces a reconciliation report, and can optionally write a flag for a human to review. Full code, tests, and a dry run guard are below.

The problem in plain words

When a shopper checks out with a coupon code, BigCommerce increments that coupon's num_uses the instant the order is placed. That happens before anything else is known about how the order will turn out. The payment might fail a minute later. The customer might cancel. A merchant might refund it the next day, or delete the order outright while cleaning up test data. In every one of those cases, the coupon already counted a use.

The trouble is that num_uses never moves the other way. BigCommerce documents it as a system-maintained, read-only field, so there is no lifecycle hook that walks back the counter when an order that used the coupon later stops being a real sale. The number only ever goes up, drifting further from reality with every cancellation, decline, refund, or deletion, until it eventually collides with max_uses or max_uses_per_customer and blocks a shopper who has every right to use the code.

Order placed coupon code applied num_uses += 1 counted immediately Cancelled status_id 5 Declined status_id 6 Refunded status_id 4 or 14 num_uses never decremented num_uses drifts upward counts orders that never completed as a real sale Hits max_uses early real customer blocked
Every order that ever carried the coupon counts toward num_uses the moment it is placed. Whatever happens to the order afterward, the count never comes back down.

Why it happens

BigCommerce's coupon accounting is one-directional by design. A few common ways stores end up with a drifted count:

This is a common source of confusion because none of it shows up as an error. The coupon just quietly runs out sooner than the math should allow, and support only discovers the real cause after digging through order history one code at a time. See the citations at the end for the exact threads and docs.

The key insight

num_uses is documented as read-only and system-maintained, so there is no PUT or POST to /v2/coupons that can set it back to a correct value. The only way to zero it out is to delete the coupon and recreate it, which is itself destructive since there is no way to seed the new coupon with the true usage count. So the safe pattern is not "repair the number." It is "compute the true number from real orders, compare it to what BigCommerce reports, and hand the difference to a human," with any destructive rewrite gated behind an explicit flag and off by default.

The fix, as a flow

We do not touch the coupon's counter. We add a job that reads every coupon's reported num_uses, reads every order that ever carried that code through /v2/orders/{id}/coupons, keeps only the orders whose status represents a real completed or in-progress sale, and counts those as the true usage. Where the reported count is higher than the true count, the coupon is flagged for review with the delta and the offending order ids. A destructive rewrite only happens if someone explicitly opts into it.

Scheduled job runs on a timer List coupons and orders /v2/coupons, /v2/orders/{id}/coupons Filter by status_id keep only real sales num_uses > true_uses? yes no, matches Flag for review delta + order ids no rewrite by default
The script only ever produces a reconciliation report. Nothing writes to num_uses unless an operator explicitly opts into the destructive delete and recreate path with --confirm.

Build it step by step

1

Get a store hash and an access token

Create an API account in your BigCommerce control panel under Settings, API, Store-level API accounts. Read access to Marketing (for coupons) and Orders is enough for the default flag-only mode. Keep the store hash and access token in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export DRY_RUN="true"   # start safe, change to false to write the review flag
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export DRY_RUN="true"   // start safe, change to false to write the review flag
2

Talk to the V2 API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with your token in the X-Auth-Token header. A small helper sends the request and raises on a bad status. Unlike V3, V2 responses are plain JSON with no data envelope.

step2.py
import os, requests

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

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}
3

Snapshot every coupon, then find the orders that used each code

Page GET /v2/coupons?limit=250 to collect each coupon's id, code, num_uses, max_uses, and max_uses_per_customer. Then page GET /v2/orders?limit=250 and, for every order, call GET /v2/orders/{id}/coupons, which returns the coupon code and discount applied regardless of the order's status. Match those against each coupon's code to build the list of orders that ever carried it.

step3.py
def all_coupons():
    """Yield every coupon, paginated."""
    page = 1
    limit = 250
    while True:
        batch = bc("GET", f"/v2/coupons?limit={limit}&page={page}") or []
        if not batch:
            return
        for coupon in batch:
            yield coupon
        if len(batch) < limit:
            return
        page += 1


def all_orders(min_date_created=None):
    """Yield every order, paginated, optionally from a date forward."""
    page = 1
    limit = 250
    while True:
        qs = f"limit={limit}&page={page}"
        if min_date_created:
            qs += f"&min_date_created={min_date_created}"
        batch = bc("GET", f"/v2/orders?{qs}") or []
        if not batch:
            return
        for order in batch:
            yield order
        if len(batch) < limit:
            return
        page += 1


def order_coupon_codes(order_id):
    """Return the list of coupon codes applied to one order, regardless of status."""
    rows = bc("GET", f"/v2/orders/{order_id}/coupons") or []
    return [row["code"] for row in rows]
step3.js
async function* allCoupons() {
  const limit = 250;
  let page = 1;
  while (true) {
    const batch = (await bc("GET", `/v2/coupons?limit=${limit}&page=${page}`)) || [];
    if (!batch.length) return;
    for (const coupon of batch) yield coupon;
    if (batch.length < limit) return;
    page += 1;
  }
}

async function* allOrders(minDateCreated) {
  const limit = 250;
  let page = 1;
  while (true) {
    let qs = `limit=${limit}&page=${page}`;
    if (minDateCreated) qs += `&min_date_created=${minDateCreated}`;
    const batch = (await bc("GET", `/v2/orders?${qs}`)) || [];
    if (!batch.length) return;
    for (const order of batch) yield order;
    if (batch.length < limit) return;
    page += 1;
  }
}

async function orderCouponCodes(orderId) {
  const rows = (await bc("GET", `/v2/orders/${orderId}/coupons`)) || [];
  return rows.map((row) => row.code);
}
4

Reconcile with one pure function

Keep the decision in its own function that takes a coupon and the full list of orders that carried its code, and returns the reconciliation. A pure function like this is easy to read and easy to test, which we do later. It counts an order toward true_uses only when its status_id is in the set of statuses that represent a real completed or in-progress sale. Everything else, Incomplete, Cancelled, Declined, Refunded, and Partially Refunded by default, is treated as inflation and listed in offending_order_ids.

reconcile.py
VALID_STATUS_IDS = frozenset({2, 3, 7, 8, 9, 10, 11})
# 2 Shipped, 3 Partially Shipped, 7 Awaiting Payment, 8 Awaiting Pickup,
# 9 Awaiting Shipment, 10 Completed, 11 Awaiting Fulfillment

def reconcile_coupon_usage(coupon, orders_with_coupon,
                            valid_status_ids=VALID_STATUS_IDS, tolerance=0):
    """coupon: {"id","code","num_uses","max_uses","max_uses_per_customer"}
    orders_with_coupon: [{"order_id","status_id","coupon_code"}, ...]
    Pure, no I/O. Returns the reconciliation dict for one coupon."""
    true_uses = sum(1 for o in orders_with_coupon if o["status_id"] in valid_status_ids)
    delta = coupon["num_uses"] - true_uses
    offending_order_ids = sorted(
        o["order_id"] for o in orders_with_coupon if o["status_id"] not in valid_status_ids
    )
    return {
        "coupon_id": coupon["id"],
        "code": coupon["code"],
        "reported_uses": coupon["num_uses"],
        "true_uses": true_uses,
        "delta": delta,
        "drifted": delta > tolerance,
        "offending_order_ids": offending_order_ids,
    }
reconcile.js
const VALID_STATUS_IDS = new Set([2, 3, 7, 8, 9, 10, 11]);
// 2 Shipped, 3 Partially Shipped, 7 Awaiting Payment, 8 Awaiting Pickup,
// 9 Awaiting Shipment, 10 Completed, 11 Awaiting Fulfillment

export function reconcileCouponUsage(coupon, ordersWithCoupon, validStatusIds = VALID_STATUS_IDS, tolerance = 0) {
  const trueUses = ordersWithCoupon.filter((o) => validStatusIds.has(o.statusId)).length;
  const delta = coupon.numUses - trueUses;
  const offendingOrderIds = ordersWithCoupon
    .filter((o) => !validStatusIds.has(o.statusId))
    .map((o) => o.orderId)
    .sort((a, b) => a - b);

  return {
    couponId: coupon.id,
    code: coupon.code,
    reportedUses: coupon.numUses,
    trueUses,
    delta,
    drifted: delta > tolerance,
    offendingOrderIds,
  };
}
5

Flag the drift, never rewrite num_uses

num_uses cannot be corrected through a PUT or POST to /v2/coupons, since it is read-only and system-maintained. The only way to reset it is to DELETE /v2/coupons/{id} and POST /v2/coupons again with the same code, type, amount, max_uses, and expiry, which resets usage to zero and cannot be seeded with the true count. That is destructive, so it must be gated behind an explicit --confirm flag and off by default. The default action is to write the reconciliation result to a review queue, for example your own metadata store or a note, for a human to decide.

flag.py
def flag_for_review(result, review_queue):
    """The only 'write' the default flow performs: append to a review queue.
    Never touches num_uses. review_queue is any append-only sink you control,
    for example a database table, a file, or an app's own metadata store."""
    review_queue.append({
        "coupon_id": result["coupon_id"],
        "code": result["code"],
        "reported_uses": result["reported_uses"],
        "true_uses": result["true_uses"],
        "delta": result["delta"],
        "offending_order_ids": result["offending_order_ids"],
    })


def reset_coupon_destructive(coupon):
    """DELETE + POST to reset num_uses to 0. Destructive: usage history is lost
    and cannot be seeded. Only ever called when --confirm is explicitly passed."""
    bc("DELETE", f"/v2/coupons/{coupon['id']}")
    return bc("POST", "/v2/coupons", json={
        "code": coupon["code"],
        "type": coupon["type"],
        "amount": coupon["amount"],
        "max_uses": coupon.get("max_uses"),
        "expires": coupon.get("expires"),
    })
flag.js
function flagForReview(result, reviewQueue) {
  // The only "write" the default flow performs: append to a review queue.
  // Never touches num_uses. reviewQueue is any append-only sink you control.
  reviewQueue.push({
    couponId: result.couponId,
    code: result.code,
    reportedUses: result.reportedUses,
    trueUses: result.trueUses,
    delta: result.delta,
    offendingOrderIds: result.offendingOrderIds,
  });
}

async function resetCouponDestructive(coupon) {
  // DELETE + POST to reset num_uses to 0. Destructive: usage history is lost
  // and cannot be seeded. Only ever called when --confirm is explicitly passed.
  await bc("DELETE", `/v2/coupons/${coupon.id}`);
  return bc("POST", "/v2/coupons", {
    code: coupon.code,
    type: coupon.type,
    amount: coupon.amount,
    max_uses: coupon.maxUses ?? null,
    expires: coupon.expires ?? null,
  });
}
6

Wire it together with a dry run guard and a confirm gate

The loop pages every coupon, gathers the orders that carried its code, reconciles the two numbers, and logs every drifted coupon with its delta and offending order ids. DRY_RUN controls whether the review queue actually receives a write. The destructive delete and recreate path is not wired into the default loop at all. It lives behind a separate --confirm flag so it can never run by accident. Run the reconciliation on a schedule, for example nightly, so drift never sits unnoticed for long.

Run it safe

Always start with DRY_RUN=true. Treat every flagged coupon as a lead for a human to check, not an automatic correction. Only pass --confirm to reset a coupon's count if you have accepted that its full usage history resets to zero, since there is no API path that can seed the new coupon with the true number.

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 never writes to num_uses. It is safe to run again and again because reconciling is read-only, and the only mutation, flagging for review, is idempotent.

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

reconcile_coupon_usage.py
"""Reconcile BigCommerce coupon usage counts against real completed orders.

BigCommerce increments a coupon's num_uses on /v2/coupons the instant an order
is placed with that code applied. It never decrements it when the order is
later cancelled, declined, refunded, or manually edited or deleted, because
num_uses is documented as a read-only, system-maintained field that cannot be
corrected through a PUT or POST. The stored count drifts upward relative to
real usage until it collides with max_uses or max_uses_per_customer and blocks
a legitimate customer.

This pages GET /v2/coupons for every coupon's reported num_uses, pages
GET /v2/orders plus GET /v2/orders/{id}/coupons to find every order that ever
carried each code, keeps only the orders whose status_id represents a real
completed or in-progress sale, and reconciles the two numbers with a pure
function. It never writes to num_uses. The default action is to flag drifted
coupons to a review queue. A destructive delete-and-recreate reset is available
only behind an explicit --confirm flag, off by default. Guarded by DRY_RUN.
Safe to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/coupon-usage-miscounts/
"""
import os
import sys
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
MIN_DATE_CREATED = os.environ.get("MIN_DATE_CREATED")  # e.g. "2026-01-01T00:00:00"

VALID_STATUS_IDS = frozenset({2, 3, 7, 8, 9, 10, 11})
# 2 Shipped, 3 Partially Shipped, 7 Awaiting Payment, 8 Awaiting Pickup,
# 9 Awaiting Shipment, 10 Completed, 11 Awaiting Fulfillment
# Excluded: 0 Incomplete, 5 Cancelled, 6 Declined, 4 Refunded, 14 Partially Refunded,
# 1 Pending, 12 Manual Verification Required, 13 Disputed


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    return r.json()


def reconcile_coupon_usage(coupon, orders_with_coupon,
                            valid_status_ids=VALID_STATUS_IDS, tolerance=0):
    """coupon: {"id","code","num_uses","max_uses","max_uses_per_customer"}
    orders_with_coupon: [{"order_id","status_id","coupon_code"}, ...]
    Pure, no I/O. Decision logic only: counts true_uses from valid statuses,
    computes delta and drifted, and lists every non-valid-status order that
    still counted toward num_uses as the presumed source of inflation."""
    true_uses = sum(1 for o in orders_with_coupon if o["status_id"] in valid_status_ids)
    delta = coupon["num_uses"] - true_uses
    offending_order_ids = sorted(
        o["order_id"] for o in orders_with_coupon if o["status_id"] not in valid_status_ids
    )
    return {
        "coupon_id": coupon["id"],
        "code": coupon["code"],
        "reported_uses": coupon["num_uses"],
        "true_uses": true_uses,
        "delta": delta,
        "drifted": delta > tolerance,
        "offending_order_ids": offending_order_ids,
    }


def all_coupons():
    page = 1
    limit = 250
    while True:
        batch = bc("GET", f"/v2/coupons?limit={limit}&page={page}") or []
        if not batch:
            return
        for coupon in batch:
            yield coupon
        if len(batch) < limit:
            return
        page += 1


def all_orders():
    page = 1
    limit = 250
    while True:
        qs = f"limit={limit}&page={page}"
        if MIN_DATE_CREATED:
            qs += f"&min_date_created={MIN_DATE_CREATED}"
        batch = bc("GET", f"/v2/orders?{qs}") or []
        if not batch:
            return
        for order in batch:
            yield order
        if len(batch) < limit:
            return
        page += 1


def order_coupon_codes(order_id):
    """Every coupon code applied to one order, regardless of the order's status."""
    rows = bc("GET", f"/v2/orders/{order_id}/coupons") or []
    return [row["code"] for row in rows]


def build_orders_by_code():
    """Walk every order once, and bucket {order_id, status_id} by coupon code."""
    by_code = {}
    for order in all_orders():
        codes = order_coupon_codes(order["id"])
        for code in codes:
            by_code.setdefault(code, []).append({
                "order_id": order["id"],
                "status_id": order["status_id"],
                "coupon_code": code,
            })
    return by_code


def flag_for_review(result, review_queue):
    """The only 'write' the default flow performs: append to a review queue.
    Never touches num_uses. review_queue is any append-only sink you control."""
    review_queue.append({
        "coupon_id": result["coupon_id"],
        "code": result["code"],
        "reported_uses": result["reported_uses"],
        "true_uses": result["true_uses"],
        "delta": result["delta"],
        "offending_order_ids": result["offending_order_ids"],
    })


def reset_coupon_destructive(coupon):
    """DELETE + POST to reset num_uses to 0. Destructive: usage history is lost
    and cannot be seeded with the true count. Only called when --confirm is passed."""
    bc("DELETE", f"/v2/coupons/{coupon['id']}")
    return bc("POST", "/v2/coupons", json={
        "code": coupon["code"],
        "type": coupon["type"],
        "amount": coupon["amount"],
        "max_uses": coupon.get("max_uses"),
        "expires": coupon.get("expires"),
    })


def run(confirm_reset=False):
    orders_by_code = build_orders_by_code()
    review_queue = []
    drifted_count = 0

    for coupon in all_coupons():
        orders_with_coupon = orders_by_code.get(coupon["code"], [])
        result = reconcile_coupon_usage(coupon, orders_with_coupon)
        if not result["drifted"]:
            continue

        drifted_count += 1
        log.warning(
            "Coupon %r (id=%s) reports %s uses, true usage is %s, delta %s. Offending orders: %s. %s",
            result["code"], result["coupon_id"], result["reported_uses"],
            result["true_uses"], result["delta"], result["offending_order_ids"],
            "would flag" if DRY_RUN else "flagging",
        )
        if not DRY_RUN:
            flag_for_review(result, review_queue)

        if confirm_reset and not DRY_RUN:
            log.warning("Resetting coupon %r via delete and recreate. Usage history will reset to 0.", result["code"])
            reset_coupon_destructive(coupon)

    log.info("Done. %d coupon(s) drifted, %d flagged.", drifted_count, len(review_queue))
    return review_queue


if __name__ == "__main__":
    confirm = "--confirm" in sys.argv
    run(confirm_reset=confirm)
reconcile-coupon-usage.js
/**
 * Reconcile BigCommerce coupon usage counts against real completed orders.
 *
 * BigCommerce increments a coupon's num_uses on /v2/coupons the instant an
 * order is placed with that code applied. It never decrements it when the
 * order is later cancelled, declined, refunded, or manually edited or deleted,
 * because num_uses is documented as a read-only, system-maintained field that
 * cannot be corrected through a PUT or POST. The stored count drifts upward
 * relative to real usage until it collides with max_uses or
 * max_uses_per_customer and blocks a legitimate customer.
 *
 * This pages GET /v2/coupons for every coupon's reported num_uses, pages
 * GET /v2/orders plus GET /v2/orders/{id}/coupons to find every order that
 * ever carried each code, keeps only the orders whose status_id represents a
 * real completed or in-progress sale, and reconciles the two numbers with a
 * pure function. It never writes to num_uses. The default action is to flag
 * drifted coupons to a review queue. A destructive delete-and-recreate reset
 * is available only behind an explicit --confirm flag, off by default.
 * Guarded by DRY_RUN. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/coupon-usage-miscounts/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "dummy_store_hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const MIN_DATE_CREATED = process.env.MIN_DATE_CREATED || "";

const VALID_STATUS_IDS = new Set([2, 3, 7, 8, 9, 10, 11]);
// 2 Shipped, 3 Partially Shipped, 7 Awaiting Payment, 8 Awaiting Pickup,
// 9 Awaiting Shipment, 10 Completed, 11 Awaiting Fulfillment
// Excluded: 0 Incomplete, 5 Cancelled, 6 Declined, 4 Refunded, 14 Partially Refunded,
// 1 Pending, 12 Manual Verification Required, 13 Disputed

export function reconcileCouponUsage(coupon, ordersWithCoupon, validStatusIds = VALID_STATUS_IDS, tolerance = 0) {
  const trueUses = ordersWithCoupon.filter((o) => validStatusIds.has(o.statusId)).length;
  const delta = coupon.numUses - trueUses;
  const offendingOrderIds = ordersWithCoupon
    .filter((o) => !validStatusIds.has(o.statusId))
    .map((o) => o.orderId)
    .sort((a, b) => a - b);

  return {
    couponId: coupon.id,
    code: coupon.code,
    reportedUses: coupon.numUses,
    trueUses,
    delta,
    drifted: delta > tolerance,
    offendingOrderIds,
  };
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : null;
}

async function* allCoupons() {
  const limit = 250;
  let page = 1;
  while (true) {
    const batch = (await bc("GET", `/v2/coupons?limit=${limit}&page=${page}`)) || [];
    if (!batch.length) return;
    for (const coupon of batch) yield coupon;
    if (batch.length < limit) return;
    page += 1;
  }
}

async function* allOrders() {
  const limit = 250;
  let page = 1;
  while (true) {
    let qs = `limit=${limit}&page=${page}`;
    if (MIN_DATE_CREATED) qs += `&min_date_created=${MIN_DATE_CREATED}`;
    const batch = (await bc("GET", `/v2/orders?${qs}`)) || [];
    if (!batch.length) return;
    for (const order of batch) yield order;
    if (batch.length < limit) return;
    page += 1;
  }
}

async function orderCouponCodes(orderId) {
  const rows = (await bc("GET", `/v2/orders/${orderId}/coupons`)) || [];
  return rows.map((row) => row.code);
}

async function buildOrdersByCode() {
  const byCode = new Map();
  for await (const order of allOrders()) {
    const codes = await orderCouponCodes(order.id);
    for (const code of codes) {
      if (!byCode.has(code)) byCode.set(code, []);
      byCode.get(code).push({ orderId: order.id, statusId: order.status_id, couponCode: code });
    }
  }
  return byCode;
}

function flagForReview(result, reviewQueue) {
  // The only "write" the default flow performs: append to a review queue.
  // Never touches num_uses. reviewQueue is any append-only sink you control.
  reviewQueue.push({
    couponId: result.couponId,
    code: result.code,
    reportedUses: result.reportedUses,
    trueUses: result.trueUses,
    delta: result.delta,
    offendingOrderIds: result.offendingOrderIds,
  });
}

async function resetCouponDestructive(coupon) {
  // DELETE + POST to reset num_uses to 0. Destructive: usage history is lost
  // and cannot be seeded with the true count. Only called when --confirm is passed.
  await bc("DELETE", `/v2/coupons/${coupon.id}`);
  return bc("POST", "/v2/coupons", {
    code: coupon.code,
    type: coupon.type,
    amount: coupon.amount,
    max_uses: coupon.max_uses ?? null,
    expires: coupon.expires ?? null,
  });
}

export async function run(confirmReset = false) {
  const ordersByCode = await buildOrdersByCode();
  const reviewQueue = [];
  let driftedCount = 0;

  for await (const coupon of allCoupons()) {
    const ordersWithCoupon = ordersByCode.get(coupon.code) || [];
    const normalizedCoupon = { id: coupon.id, code: coupon.code, numUses: coupon.num_uses };
    const result = reconcileCouponUsage(normalizedCoupon, ordersWithCoupon);
    if (!result.drifted) continue;

    driftedCount++;
    console.warn(
      `Coupon "${result.code}" (id=${result.couponId}) reports ${result.reportedUses} uses, true usage is ${result.trueUses}, delta ${result.delta}. Offending orders: ${JSON.stringify(result.offendingOrderIds)}. ${DRY_RUN ? "would flag" : "flagging"}`
    );
    if (!DRY_RUN) flagForReview(result, reviewQueue);

    if (confirmReset && !DRY_RUN) {
      console.warn(`Resetting coupon "${result.code}" via delete and recreate. Usage history will reset to 0.`);
      await resetCouponDestructive(coupon);
    }
  }

  console.log(`Done. ${driftedCount} coupon(s) drifted, ${reviewQueue.length} flagged.`);
  return reviewQueue;
}

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

Add a test

The reconciliation rule is the part most worth testing, because it decides which coupons get reported as drifted and which orders are named as the cause. Because we kept reconcile_coupon_usage pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_coupon_reconciliation.py
from reconcile_coupon_usage import reconcile_coupon_usage


def coupon(id=1, code="SAVE10", num_uses=5, max_uses=10, max_uses_per_customer=None):
    return {"id": id, "code": code, "num_uses": num_uses,
            "max_uses": max_uses, "max_uses_per_customer": max_uses_per_customer}


def order(order_id, status_id, code="SAVE10"):
    return {"order_id": order_id, "status_id": status_id, "coupon_code": code}


def test_no_drift_when_reported_matches_true():
    orders = [order(1, 10), order(2, 10), order(3, 2)]
    result = reconcile_coupon_usage(coupon(num_uses=3), orders)
    assert result["true_uses"] == 3
    assert result["delta"] == 0
    assert result["drifted"] is False
    assert result["offending_order_ids"] == []


def test_drift_when_cancelled_orders_still_counted():
    orders = [order(1, 10), order(2, 5), order(3, 6)]
    result = reconcile_coupon_usage(coupon(num_uses=3), orders)
    assert result["true_uses"] == 1
    assert result["delta"] == 2
    assert result["drifted"] is True
    assert result["offending_order_ids"] == [2, 3]


def test_refunded_and_partially_refunded_are_offending():
    orders = [order(1, 10), order(2, 4), order(3, 14)]
    result = reconcile_coupon_usage(coupon(num_uses=3), orders)
    assert result["true_uses"] == 1
    assert result["offending_order_ids"] == [2, 3]


def test_no_orders_at_all_is_full_delta():
    result = reconcile_coupon_usage(coupon(num_uses=4), [])
    assert result["true_uses"] == 0
    assert result["delta"] == 4
    assert result["drifted"] is True
    assert result["offending_order_ids"] == []


def test_tolerance_absorbs_small_delta():
    orders = [order(1, 10)]
    result = reconcile_coupon_usage(coupon(num_uses=2), orders, tolerance=1)
    assert result["delta"] == 1
    assert result["drifted"] is False


def test_negative_delta_is_not_drifted():
    orders = [order(1, 10), order(2, 10), order(3, 10)]
    result = reconcile_coupon_usage(coupon(num_uses=1), orders)
    assert result["delta"] == -2
    assert result["drifted"] is False


def test_offending_order_ids_sorted():
    orders = [order(9, 5), order(2, 6), order(7, 0)]
    result = reconcile_coupon_usage(coupon(num_uses=3), orders)
    assert result["offending_order_ids"] == [2, 7, 9]


def test_awaiting_payment_counts_as_valid():
    orders = [order(1, 7)]
    result = reconcile_coupon_usage(coupon(num_uses=1), orders)
    assert result["true_uses"] == 1
    assert result["drifted"] is False
reconcile-coupon-usage.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reconcileCouponUsage } from "./reconcile-coupon-usage.js";

const coupon = ({ id = 1, code = "SAVE10", numUses = 5 } = {}) => ({ id, code, numUses });
const order = (orderId, statusId, couponCode = "SAVE10") => ({ orderId, statusId, couponCode });

test("no drift when reported matches true", () => {
  const orders = [order(1, 10), order(2, 10), order(3, 2)];
  const result = reconcileCouponUsage(coupon({ numUses: 3 }), orders);
  assert.equal(result.trueUses, 3);
  assert.equal(result.delta, 0);
  assert.equal(result.drifted, false);
  assert.deepEqual(result.offendingOrderIds, []);
});

test("drift when cancelled orders still counted", () => {
  const orders = [order(1, 10), order(2, 5), order(3, 6)];
  const result = reconcileCouponUsage(coupon({ numUses: 3 }), orders);
  assert.equal(result.trueUses, 1);
  assert.equal(result.delta, 2);
  assert.equal(result.drifted, true);
  assert.deepEqual(result.offendingOrderIds, [2, 3]);
});

test("refunded and partially refunded are offending", () => {
  const orders = [order(1, 10), order(2, 4), order(3, 14)];
  const result = reconcileCouponUsage(coupon({ numUses: 3 }), orders);
  assert.equal(result.trueUses, 1);
  assert.deepEqual(result.offendingOrderIds, [2, 3]);
});

test("no orders at all is full delta", () => {
  const result = reconcileCouponUsage(coupon({ numUses: 4 }), []);
  assert.equal(result.trueUses, 0);
  assert.equal(result.delta, 4);
  assert.equal(result.drifted, true);
  assert.deepEqual(result.offendingOrderIds, []);
});

test("tolerance absorbs small delta", () => {
  const orders = [order(1, 10)];
  const result = reconcileCouponUsage(coupon({ numUses: 2 }), orders, undefined, 1);
  assert.equal(result.delta, 1);
  assert.equal(result.drifted, false);
});

test("negative delta is not drifted", () => {
  const orders = [order(1, 10), order(2, 10), order(3, 10)];
  const result = reconcileCouponUsage(coupon({ numUses: 1 }), orders);
  assert.equal(result.delta, -2);
  assert.equal(result.drifted, false);
});

test("offending order ids sorted", () => {
  const orders = [order(9, 5), order(2, 6), order(7, 0)];
  const result = reconcileCouponUsage(coupon({ numUses: 3 }), orders);
  assert.deepEqual(result.offendingOrderIds, [2, 7, 9]);
});

test("awaiting payment counts as valid", () => {
  const orders = [order(1, 7)];
  const result = reconcileCouponUsage(coupon({ numUses: 1 }), orders);
  assert.equal(result.trueUses, 1);
  assert.equal(result.drifted, false);
});

Case studies

Declined payments

The launch code that ran out in an afternoon

A skincare brand ran a one-day LAUNCH20 code capped at 200 uses. By early evening the coupon reported max_uses reached, but the order list showed barely 140 orders that had actually shipped. A wave of declined cards during a payment gateway hiccup had each still counted a use before the decline came back.

Running the reconciliation script showed the code's true usage was 143, a delta of 57 against declined and abandoned orders. The team could not un-ring that bell for the day, but they raised the cap for a follow-up code and now run the check hourly during launches so it never surprises them again.

Refund cleanup

The loyalty code that quietly starved regulars

A subscription box store gave each customer a personal max_uses_per_customer code good for one redemption a month. A handful of customers who returned their box and got a refund found the same code rejected the next month, even though the refunded month should not have counted.

The nightly job flagged exactly those coupons, listing the refunded order id as the offending record. Support could see at a glance why a real customer was blocked and manually issue a fresh code, instead of guessing at a system bug for each ticket.

What good looks like

After this runs on a schedule, no coupon's true usage is a mystery. Every drifted code shows up in the log with the exact delta and the order ids behind it, so support can explain a blocked customer in seconds instead of digging through order history by hand. The script never guesses at what num_uses should be and never rewrites it silently, so the one-way path BigCommerce built stays honest, just visible.

FAQ

Why does a BigCommerce coupon show more uses than orders that actually completed?

BigCommerce increments a coupon's num_uses the instant an order is placed with that code applied. It never decrements it afterward, so when that order is later cancelled, declined, refunded, or edited to remove the coupon, the count stays inflated even though the discount was never honored on a real sale.

Can I fix num_uses directly through the BigCommerce API?

No. num_uses is documented as a read-only, system-maintained field on the V2 coupon object. A PUT or POST to /v2/coupons cannot set it to a corrected value, so there is no safe direct write to repair the drift.

Is deleting and recreating a coupon a safe way to reset num_uses?

It resets the count to zero, but that is destructive since there is no way to seed the new coupon with the true usage number, and it briefly removes the coupon entirely. It should only run behind an explicit confirm flag, never as a default action, and the recommended default is to flag the drift for a human to review instead.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: Using Coupon Codes. support.bigcommerce.com using-coupon-codes
  2. BigCommerce Support: coupon considered used when no order was completed. support.bigcommerce.com coupon-considered-used-when-no-order-was-completed
  3. BigCommerce Support: Processing Refunds. support.bigcommerce.com processing-refunds

On the solution:

  1. BigCommerce Docs: List Coupons, the V2 coupon object including num_uses and max_uses. docs.bigcommerce.com marketing coupons get-coupons
  2. BigCommerce Docs: Create Coupon. docs.bigcommerce.com marketing coupons create-coupon
  3. BigCommerce Developer Center: List Order Coupons. developer.bigcommerce.com rest-management orders order-coupons

Stuck on a tricky one?

If you have a problem in BigCommerce orders, catalog, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this explain your coupon count?

If this saved you a pile of manual clicks or a confused support ticket, 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