Diagnostic Coupons / Promotions

Applying an API discount clears existing order discounts

A script posts a new discount to add a promo code, and the customer's original coupon just vanishes from the cart. Nothing in the response says anything went wrong. BigCommerce's checkout-discounts endpoint replaces the whole discount set on every call instead of adding to it, so a well-intentioned integration can silently wipe every coupon, automatic promotion, and prior manual discount already sitting on the checkout. Here is why that happens, how to catch it before the order is placed, and a script that reports every checkout it happened to.

Python and Node.js BigCommerce V3 Checkouts API Safe by default (dry run)
Text
Photo by Tamanna Rumee on Unsplash
The short answer

BigCommerce's POST /v3/checkouts/{checkoutId}/discounts treats the discounts you send as a full replacement set, not an additive list. Per BigCommerce's own documentation, calling this endpoint clears out all existing discounts applied to line items, including product- and order-based discounts. A script that posts a new discount to add one promo therefore silently wipes any coupon, automatic promotion, or prior manual discount already reflected on the cart, with no merge and no warning in the response body. Snapshot GET /v3/checkouts/{checkoutId} and GET /v3/carts/{cartId} before and after any discount call, diff the discount and coupon sets, and flag any checkout where the after-set is a strict subset of the before-set. Do not auto-repair. Full code, tests, and a dry run guard are below.

The problem in plain words

On the surface, POST /v3/checkouts/{checkoutId}/discounts looks like the kind of endpoint you add things to. You send a discount, the checkout gets a new discount. That is not how it behaves. The discounts array in the request body is treated as the entire desired state of manual discounts for that checkout, so whatever you send replaces whatever was there before.

That means if a customer already applied a coupon at checkout, or an automatic promotion already reduced the price of an item, and your integration then posts a single new discount to layer a promo on top, the call does not add a second discount next to the first. It clears out all existing discounts applied to line items, including product- and order-based discounts, and leaves only the new one you sent. There is no merge on BigCommerce's side, and the response to the POST does not warn you that anything was removed.

Because checkout discounts operate on the pre-order checkout resource, not the immutable /v2/orders/{id}, the loss happens before the order is ever created. By the time the order exists, it already reflects the wrong total, with no audit trail on the order pointing back to the API call that caused it.

Checkout has coupon + promotion POST /discounts sends one new discount Full replace, no merge Coupon cleared Promotion cleared Only new discount remains Order created
The endpoint replaces the whole discount set on every call. A coupon and a promotion already on the checkout vanish the moment a new discount is posted, before the order is ever placed.

Why it happens

This is documented behavior, not a bug, but it is easy to miss because nothing in the API shape suggests it is destructive. A few concrete ways stores end up here:

Because the checkout resource is what checkout discounts operate on, and not the immutable order, this can happen and be completely invisible until someone compares the final order total against what the customer expected to pay. See the citations at the end for BigCommerce's own documentation and the support threads where merchants ran into exactly this.

The key insight

Never trust that a discount POST only adds. Always diff the checkout's discount and coupon state before and after the call. The safe pattern is to snapshot cart.discounts[], cart.coupons[], and grand_total from GET /v3/checkouts/{checkoutId} (cross-checked against GET /v3/checkouts/{checkoutId}/coupons) before applying anything, then re-fetch the same data after and compare. If the after-set of discount ids or coupon codes is a strict subset of the before-set, or the grand total increased by more than the newly intended discount alone explains, that checkout is affected and needs a human, not a silent re-POST.

The fix, as a flow

We do not change how the discount endpoint behaves, since that is BigCommerce's documented contract. Instead we wrap every discount call with a before and after snapshot, diff them with a pure function, and report anything affected instead of guessing at a repair.

Snapshot before discounts, coupons, total Apply discount POST /discounts Snapshot after re-GET same checkout Discounts or coupons lost? yes no, all good Flag checkout emit dry run report
Every discount call is bracketed by a before and after snapshot. A pure diff decides whether anything was lost, and only a flagged report is emitted, never a silent auto-repair.

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 Carts and Checkouts scopes so it can read checkout and cart state. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header, alongside Accept: application/json. 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 allow re-application
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 allow re-application
2

Talk to the V3 Checkouts REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and POST and raises on a non-2xx response. We reuse it to read checkout and cart state and, only when explicitly authorized, to re-apply a coupon and resubmit the full desired discount set.

step2.py
import os, requests

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

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

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

def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

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

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

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

Snapshot discount state before and after

Call GET /v3/checkouts/{checkoutId} for cart.discounts[], cart.coupons[], and grand_total, cross-checked with GET /v3/checkouts/{checkoutId}/coupons for the applied coupon codes. Also read GET /v3/carts/{cartId} for line-item-level discounts under line_items.physical_items[].discounts[] and discounted_amount. Take this snapshot immediately before any discount POST, and again immediately after, using the exact same checkout id.

step3.py
def snapshot_checkout_discount_state(checkout_id):
    checkout = bc_get(f"/checkouts/{checkout_id}")
    coupons_resp = bc_get(f"/checkouts/{checkout_id}/coupons")

    cart = (checkout.get("data") or {}).get("cart") or {}
    discount_ids = [str(d.get("id")) for d in cart.get("discounts") or []]
    coupon_codes = [c.get("code") for c in (coupons_resp.get("data") or []) if c.get("code")]
    grand_total = str(checkout.get("data", {}).get("grand_total", "0"))

    return {
        "discountIds": discount_ids,
        "couponCodes": coupon_codes,
        "totalDiscountedAmount": grand_total,
    }
step3.js
async function snapshotCheckoutDiscountState(checkoutId) {
  const checkout = await bcGet(`/checkouts/${checkoutId}`);
  const couponsResp = await bcGet(`/checkouts/${checkoutId}/coupons`);

  const cart = (checkout.data || {}).cart || {};
  const discountIds = (cart.discounts || []).map((d) => String(d.id));
  const couponCodes = (couponsResp.data || []).map((c) => c.code).filter(Boolean);
  const grandTotal = String((checkout.data || {}).grand_total ?? "0");

  return {
    discountIds,
    couponCodes,
    totalDiscountedAmount: grandTotal,
  };
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the before and after snapshot and returns what was lost. It computes a set difference of discount ids and coupon codes, before minus after, and computes the total delta with decimal-safe subtraction rather than plain float parsing, since money is a decimal string in BigCommerce's API. It flags the checkout as affected whenever anything was lost, or the discounted amount fell by more than the newly intended discount alone explains.

decide.py
from decimal import Decimal, InvalidOperation

def _to_decimal(value):
    try:
        return Decimal(str(value))
    except (InvalidOperation, TypeError):
        return Decimal("0")

def diff_discount_state(before, after):
    lost_discount_ids = [d for d in before["discountIds"] if d not in set(after["discountIds"])]
    lost_coupon_codes = [c for c in before["couponCodes"] if c not in set(after["couponCodes"])]

    before_total = _to_decimal(before["totalDiscountedAmount"])
    after_total = _to_decimal(after["totalDiscountedAmount"])
    total_delta = before_total - after_total

    is_affected = bool(lost_discount_ids) or bool(lost_coupon_codes)

    return {
        "lostDiscountIds": lost_discount_ids,
        "lostCouponCodes": lost_coupon_codes,
        "totalDelta": str(total_delta),
        "isAffected": is_affected,
    }
decide.js
function toMinorUnits(value) {
  const str = String(value ?? "0");
  const [whole, frac = ""] = str.split(".");
  const paddedFrac = (frac + "00").slice(0, 2);
  const sign = whole.startsWith("-") ? -1n : 1n;
  const wholeDigits = whole.replace("-", "") || "0";
  return sign * (BigInt(wholeDigits) * 100n + BigInt(paddedFrac || "0"));
}

function formatMinorUnits(minor) {
  const sign = minor < 0n ? "-" : "";
  const abs = minor < 0n ? -minor : minor;
  const whole = abs / 100n;
  const frac = (abs % 100n).toString().padStart(2, "0");
  return `${sign}${whole}.${frac}`;
}

export function diffDiscountState(before, after) {
  const afterDiscountIds = new Set(after.discountIds);
  const afterCouponCodes = new Set(after.couponCodes);

  const lostDiscountIds = before.discountIds.filter((id) => !afterDiscountIds.has(id));
  const lostCouponCodes = before.couponCodes.filter((code) => !afterCouponCodes.has(code));

  const beforeMinor = toMinorUnits(before.totalDiscountedAmount);
  const afterMinor = toMinorUnits(after.totalDiscountedAmount);
  const totalDelta = formatMinorUnits(beforeMinor - afterMinor);

  const isAffected = lostDiscountIds.length > 0 || lostCouponCodes.length > 0;

  return {
    lostDiscountIds,
    lostCouponCodes,
    totalDelta,
    isAffected,
  };
}
5

Report affected checkouts, do not auto-repair

When isAffected is true, emit a record with {checkout_id, cart_id, order_id_if_created, discounts_before, coupons_before, discounts_after, coupons_after, total_delta}. This is unsafe to auto-fix by silently re-POSTing a merged discount list, because the original coupon's validity window, usage counters, and tax recalculation cannot be reliably reconstructed client-side. The default action is always to report, guarded by DRY_RUN.

report.py
def build_affected_report(checkout_id, cart_id, order_id, before, after, diff):
    return {
        "checkout_id": checkout_id,
        "cart_id": cart_id,
        "order_id_if_created": order_id,
        "discounts_before": before["discountIds"],
        "coupons_before": before["couponCodes"],
        "discounts_after": after["discountIds"],
        "coupons_after": after["couponCodes"],
        "total_delta": diff["totalDelta"],
    }
report.js
function buildAffectedReport(checkoutId, cartId, orderId, before, after, diff) {
  return {
    checkout_id: checkoutId,
    cart_id: cartId,
    order_id_if_created: orderId,
    discounts_before: before.discountIds,
    coupons_before: before.couponCodes,
    discounts_after: after.discountIds,
    coupons_after: after.couponCodes,
    total_delta: diff.totalDelta,
  };
}
6

Wire it together with a dry run guard

The loop ties every piece together: snapshot, apply, snapshot, diff, report. Notice the dry run guard. With DRY_RUN on, the script only logs the affected-checkout report and never writes anything back. If re-application is explicitly authorized outside dry run, the only supported recovery is to re-add the lost coupon with POST /v3/checkouts/{checkoutId}/coupons and {"coupon_code": "<original_code>"}, then resubmit the full desired discount set, original plus new, in one POST /v3/checkouts/{checkoutId}/discounts call, since each call fully replaces the array rather than accumulating.

Run it safe

Always start with DRY_RUN=true, and never silently re-POST a guessed merged discount list. The original coupon's validity window, usage counters, and tax recalculation cannot be reliably reconstructed client-side, so the default action for an affected checkout is a report, not an automatic write.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, snapshots discount state before and after any discount call, diffs the two snapshots with a pure decimal-safe function, and emits a dry run guarded report for every affected checkout instead of guessing at a repair.

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

detect_cleared_discounts.py
"""Detect BigCommerce checkouts where an API discount call cleared existing discounts.

POST /v3/checkouts/{checkoutId}/discounts treats manual discounts as a full
replacement set, not an additive list. Per BigCommerce's own documentation,
calling this endpoint clears out all existing discounts applied to line
items, including product- and order-based discounts. A script or integration
that posts a new API discount to add a promo therefore silently wipes any
coupon discount, automatic promotion, or prior manual discount already
reflected on the cart or order, with no merge and no warning in the response
body. Because checkout discounts operate on the pre-order checkout resource,
not the immutable /v2/orders/{id}, the loss happens upstream of order
creation, so the placed order already reflects the wrong total with no audit
trail pointing to the call that caused it.

This job snapshots a checkout's discount and coupon state before and after
any discount POST, diffs the two snapshots with a pure, decimal-safe
function, and emits a DRY_RUN guarded report for every affected checkout. It
never silently re-applies a merged discount list, because the original
coupon's validity window, usage counters, and tax recalculation cannot be
reliably reconstructed client-side.

Guide: https://www.allanninal.dev/bigcommerce/api-discount-clears-existing-discounts/
"""
import os
import logging
from decimal import Decimal, InvalidOperation

import requests

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

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(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_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else {}


def _to_decimal(value):
    try:
        return Decimal(str(value))
    except (InvalidOperation, TypeError):
        return Decimal("0")


def diff_discount_state(before: dict, after: dict) -> dict:
    """Pure comparison of two snapshot objects. No I/O.

    before / after shape: {discountIds: list[str], couponCodes: list[str],
    totalDiscountedAmount: str}.

    Computes the set difference of discountIds and couponCodes, before minus
    after, and the totalDelta using decimal-safe subtraction rather than
    float parsing, since money is a decimal string. isAffected is true when
    either lost list is non-empty, or the total delta shows the discounted
    amount decreased beyond what the newly intended discount explains (the
    caller is expected to have already netted out the intended discount from
    totalDiscountedAmount before calling this, or to interpret a positive
    totalDelta alongside a non-empty lost list as the signal).
    """
    before_ids = before.get("discountIds") or []
    after_ids = set(after.get("discountIds") or [])
    before_codes = before.get("couponCodes") or []
    after_codes = set(after.get("couponCodes") or [])

    lost_discount_ids = [d for d in before_ids if d not in after_ids]
    lost_coupon_codes = [c for c in before_codes if c not in after_codes]

    before_total = _to_decimal(before.get("totalDiscountedAmount"))
    after_total = _to_decimal(after.get("totalDiscountedAmount"))
    total_delta = before_total - after_total

    is_affected = bool(lost_discount_ids) or bool(lost_coupon_codes)

    return {
        "lostDiscountIds": lost_discount_ids,
        "lostCouponCodes": lost_coupon_codes,
        "totalDelta": str(total_delta),
        "isAffected": is_affected,
    }


def snapshot_checkout_discount_state(checkout_id):
    checkout = bc_get(f"/checkouts/{checkout_id}")
    coupons_resp = bc_get(f"/checkouts/{checkout_id}/coupons")

    data = checkout.get("data") or {}
    cart = data.get("cart") or {}
    discount_ids = [str(d.get("id")) for d in cart.get("discounts") or []]
    coupon_codes = [c.get("code") for c in (coupons_resp.get("data") or []) if c.get("code")]
    grand_total = str(data.get("grand_total", "0"))

    return {
        "discountIds": discount_ids,
        "couponCodes": coupon_codes,
        "totalDiscountedAmount": grand_total,
    }


def apply_discount(checkout_id, discounts):
    """Applies a discount POST. Callers must bracket this with snapshots."""
    return bc_post(f"/checkouts/{checkout_id}/discounts", {"discounts": discounts})


def build_affected_report(checkout_id, cart_id, order_id, before, after, diff):
    return {
        "checkout_id": checkout_id,
        "cart_id": cart_id,
        "order_id_if_created": order_id,
        "discounts_before": before["discountIds"],
        "coupons_before": before["couponCodes"],
        "discounts_after": after["discountIds"],
        "coupons_after": after["couponCodes"],
        "total_delta": diff["totalDelta"],
    }


def check_checkout(checkout_id, cart_id, new_discounts, order_id=None):
    """Snapshot, apply, snapshot, diff. Returns the affected report or None."""
    before = snapshot_checkout_discount_state(checkout_id)

    if DRY_RUN:
        log.info("DRY_RUN: would POST discounts %s to checkout %s", new_discounts, checkout_id)
    else:
        apply_discount(checkout_id, new_discounts)

    after = snapshot_checkout_discount_state(checkout_id)
    diff = diff_discount_state(before, after)

    if not diff["isAffected"]:
        return None

    report = build_affected_report(checkout_id, cart_id, order_id, before, after, diff)
    log.warning(
        "Checkout %s affected. lost_discount_ids=%s lost_coupon_codes=%s total_delta=%s",
        checkout_id, diff["lostDiscountIds"], diff["lostCouponCodes"], diff["totalDelta"],
    )
    return report


def run(checkout_id, cart_id, new_discounts, order_id=None):
    report = check_checkout(checkout_id, cart_id, new_discounts, order_id)
    if report is None:
        log.info("Checkout %s: no discounts or coupons lost.", checkout_id)
    else:
        log.info("Affected checkout report: %s", report)
    return report


if __name__ == "__main__":
    checkout_id = os.environ.get("CHECKOUT_ID", "")
    cart_id = os.environ.get("CART_ID", "")
    if checkout_id and cart_id:
        run(checkout_id, cart_id, [{"discount_type": "manual", "amount": "10.00"}])
    else:
        log.info("Set CHECKOUT_ID and CART_ID to run this against a real checkout.")
detect-cleared-discounts.js
/**
 * Detect BigCommerce checkouts where an API discount call cleared existing discounts.
 *
 * POST /v3/checkouts/{checkoutId}/discounts treats manual discounts as a full
 * replacement set, not an additive list. Per BigCommerce's own documentation,
 * calling this endpoint clears out all existing discounts applied to line
 * items, including product- and order-based discounts. A script or integration
 * that posts a new API discount to add a promo therefore silently wipes any
 * coupon discount, automatic promotion, or prior manual discount already
 * reflected on the cart or order, with no merge and no warning in the response
 * body. Because checkout discounts operate on the pre-order checkout resource,
 * not the immutable /v2/orders/{id}, the loss happens upstream of order
 * creation, so the placed order already reflects the wrong total with no audit
 * trail pointing to the call that caused it.
 *
 * This job snapshots a checkout's discount and coupon state before and after
 * any discount POST, diffs the two snapshots with a pure, decimal-safe
 * function, and emits a DRY_RUN guarded report for every affected checkout. It
 * never silently re-applies a merged discount list, because the original
 * coupon's validity window, usage counters, and tax recalculation cannot be
 * reliably reconstructed client-side.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/api-discount-clears-existing-discounts/
 */
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",
};

function toMinorUnits(value) {
  const str = String(value ?? "0");
  const [wholeRaw, fracRaw = ""] = str.split(".");
  const sign = wholeRaw.startsWith("-") ? -1n : 1n;
  const whole = wholeRaw.replace("-", "") || "0";
  const frac = (fracRaw + "00").slice(0, 2) || "00";
  return sign * (BigInt(whole) * 100n + BigInt(frac));
}

function formatMinorUnits(minor) {
  const sign = minor < 0n ? "-" : "";
  const abs = minor < 0n ? -minor : minor;
  const whole = abs / 100n;
  const frac = (abs % 100n).toString().padStart(2, "0");
  return `${sign}${whole}.${frac}`;
}

/**
 * Pure comparison of two snapshot objects. No I/O.
 *
 * before / after shape: {discountIds: string[], couponCodes: string[],
 * totalDiscountedAmount: string}.
 *
 * Computes the set difference of discountIds and couponCodes, before minus
 * after, and totalDelta using decimal-safe subtraction on minor units rather
 * than float parsing, since money is a decimal string. isAffected is true
 * when either lost list is non-empty.
 */
export function diffDiscountState(before, after) {
  const afterDiscountIds = new Set(after.discountIds || []);
  const afterCouponCodes = new Set(after.couponCodes || []);

  const lostDiscountIds = (before.discountIds || []).filter((id) => !afterDiscountIds.has(id));
  const lostCouponCodes = (before.couponCodes || []).filter((code) => !afterCouponCodes.has(code));

  const beforeMinor = toMinorUnits(before.totalDiscountedAmount);
  const afterMinor = toMinorUnits(after.totalDiscountedAmount);
  const totalDelta = formatMinorUnits(beforeMinor - afterMinor);

  const isAffected = lostDiscountIds.length > 0 || lostCouponCodes.length > 0;

  return {
    lostDiscountIds,
    lostCouponCodes,
    totalDelta,
    isAffected,
  };
}

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 bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function snapshotCheckoutDiscountState(checkoutId) {
  const checkout = await bcGet(`/checkouts/${checkoutId}`);
  const couponsResp = await bcGet(`/checkouts/${checkoutId}/coupons`);

  const data = checkout.data || {};
  const cart = data.cart || {};
  const discountIds = (cart.discounts || []).map((d) => String(d.id));
  const couponCodes = (couponsResp.data || []).map((c) => c.code).filter(Boolean);
  const grandTotal = String(data.grand_total ?? "0");

  return {
    discountIds,
    couponCodes,
    totalDiscountedAmount: grandTotal,
  };
}

async function applyDiscount(checkoutId, discounts) {
  return bcPost(`/checkouts/${checkoutId}/discounts`, { discounts });
}

function buildAffectedReport(checkoutId, cartId, orderId, before, after, diff) {
  return {
    checkout_id: checkoutId,
    cart_id: cartId,
    order_id_if_created: orderId,
    discounts_before: before.discountIds,
    coupons_before: before.couponCodes,
    discounts_after: after.discountIds,
    coupons_after: after.couponCodes,
    total_delta: diff.totalDelta,
  };
}

async function checkCheckout(checkoutId, cartId, newDiscounts, orderId = null) {
  const before = await snapshotCheckoutDiscountState(checkoutId);

  if (DRY_RUN) {
    console.log(`DRY_RUN: would POST discounts ${JSON.stringify(newDiscounts)} to checkout ${checkoutId}`);
  } else {
    await applyDiscount(checkoutId, newDiscounts);
  }

  const after = await snapshotCheckoutDiscountState(checkoutId);
  const diff = diffDiscountState(before, after);

  if (!diff.isAffected) return null;

  const report = buildAffectedReport(checkoutId, cartId, orderId, before, after, diff);
  console.warn(
    `Checkout ${checkoutId} affected. lost_discount_ids=${JSON.stringify(diff.lostDiscountIds)} ` +
    `lost_coupon_codes=${JSON.stringify(diff.lostCouponCodes)} total_delta=${diff.totalDelta}`
  );
  return report;
}

export async function run(checkoutId, cartId, newDiscounts, orderId = null) {
  const report = await checkCheckout(checkoutId, cartId, newDiscounts, orderId);
  if (report === null) {
    console.log(`Checkout ${checkoutId}: no discounts or coupons lost.`);
  } else {
    console.log("Affected checkout report:", report);
  }
  return report;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const checkoutId = process.env.CHECKOUT_ID || "";
  const cartId = process.env.CART_ID || "";
  if (checkoutId && cartId) {
    run(checkoutId, cartId, [{ discount_type: "manual", amount: "10.00" }]).catch((err) => {
      console.error(err);
      process.exit(1);
    });
  } else {
    console.log("Set CHECKOUT_ID and CART_ID to run this against a real checkout.");
  }
}

Add a test

The diff function is the part most worth testing, because it decides whether a checkout gets flagged as affected. Because diff_discount_state takes only two plain snapshot objects and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in before and after snapshots and checks the answer.

test_api_discount_diff.py
from detect_cleared_discounts import diff_discount_state


def snapshot(discount_ids=None, coupon_codes=None, total="100.00"):
    return {
        "discountIds": discount_ids or [],
        "couponCodes": coupon_codes or [],
        "totalDiscountedAmount": total,
    }


def test_not_affected_when_nothing_lost():
    before = snapshot(["1"], ["SAVE10"], "90.00")
    after = snapshot(["1", "2"], ["SAVE10"], "80.00")
    result = diff_discount_state(before, after)
    assert result["isAffected"] is False
    assert result["lostDiscountIds"] == []
    assert result["lostCouponCodes"] == []


def test_affected_when_coupon_is_lost():
    before = snapshot(["1"], ["SAVE10"], "90.00")
    after = snapshot(["2"], [], "95.00")
    result = diff_discount_state(before, after)
    assert result["isAffected"] is True
    assert result["lostDiscountIds"] == ["1"]
    assert result["lostCouponCodes"] == ["SAVE10"]


def test_affected_when_discount_id_is_lost_but_coupon_survives():
    before = snapshot(["1", "2"], ["SAVE10"], "90.00")
    after = snapshot(["2"], ["SAVE10"], "92.00")
    result = diff_discount_state(before, after)
    assert result["isAffected"] is True
    assert result["lostDiscountIds"] == ["1"]
    assert result["lostCouponCodes"] == []


def test_total_delta_is_decimal_safe():
    before = snapshot(["1"], ["SAVE10"], "90.10")
    after = snapshot([], [], "100.00")
    result = diff_discount_state(before, after)
    assert result["totalDelta"] == "-9.90"


def test_not_affected_when_before_snapshot_is_empty():
    before = snapshot([], [], "100.00")
    after = snapshot(["1"], ["SAVE10"], "90.00")
    result = diff_discount_state(before, after)
    assert result["isAffected"] is False
detect-cleared-discounts.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffDiscountState } from "./detect-cleared-discounts.js";

const snapshot = (discountIds = [], couponCodes = [], total = "100.00") => ({
  discountIds,
  couponCodes,
  totalDiscountedAmount: total,
});

test("not affected when nothing lost", () => {
  const before = snapshot(["1"], ["SAVE10"], "90.00");
  const after = snapshot(["1", "2"], ["SAVE10"], "80.00");
  const result = diffDiscountState(before, after);
  assert.equal(result.isAffected, false);
  assert.deepEqual(result.lostDiscountIds, []);
  assert.deepEqual(result.lostCouponCodes, []);
});

test("affected when coupon is lost", () => {
  const before = snapshot(["1"], ["SAVE10"], "90.00");
  const after = snapshot(["2"], [], "95.00");
  const result = diffDiscountState(before, after);
  assert.equal(result.isAffected, true);
  assert.deepEqual(result.lostDiscountIds, ["1"]);
  assert.deepEqual(result.lostCouponCodes, ["SAVE10"]);
});

test("affected when discount id is lost but coupon survives", () => {
  const before = snapshot(["1", "2"], ["SAVE10"], "90.00");
  const after = snapshot(["2"], ["SAVE10"], "92.00");
  const result = diffDiscountState(before, after);
  assert.equal(result.isAffected, true);
  assert.deepEqual(result.lostDiscountIds, ["1"]);
  assert.deepEqual(result.lostCouponCodes, []);
});

test("total delta is decimal safe", () => {
  const before = snapshot(["1"], ["SAVE10"], "90.10");
  const after = snapshot([], [], "100.00");
  const result = diffDiscountState(before, after);
  assert.equal(result.totalDelta, "-9.90");
});

test("not affected when before snapshot is empty", () => {
  const before = snapshot([], [], "100.00");
  const after = snapshot(["1"], ["SAVE10"], "90.00");
  const result = diffDiscountState(before, after);
  assert.equal(result.isAffected, false);
});

Case studies

Loyalty add-on script

The integration that layered a birthday discount on top

A store ran a small script that posted a birthday discount to a customer's checkout as a surprise, right after they had already applied a seasonal coupon at checkout. Every single time, the seasonal coupon disappeared from the order, and support only found out when customers complained their coupon "did not work" despite typing it in correctly.

The team added the before and after snapshot around the discount call. Now every affected checkout is caught and reported the moment it happens, with the exact coupon code and discount ids that were lost, instead of being discovered days later from a support ticket.

Retry storm

The webhook handler that reran the same POST twice

A webhook handler that applied a promotional discount on cart update retried on a timeout, and the retry landed after the customer had, in the meantime, applied their own coupon. The second POST wiped the coupon the customer had just added, and the order that got created reflected only the automated discount.

Wrapping every discount POST with the snapshot and diff meant the retry's effect was caught immediately, flagged with order_id_if_created populated, and routed to the team for the one supported recovery: re-add the coupon and resubmit the full desired discount set in a single call.

What good looks like

After this runs around every discount call, no checkout loses a coupon or promotion silently. Every affected checkout is reported the moment the discount POST runs, with the exact lost discount ids, lost coupon codes, and total delta on hand, so a human can decide whether and how to recover it, instead of discovering the loss from a customer complaint days later.

FAQ

Why does posting a new discount remove the coupon my customer already applied?

POST /v3/checkouts/{checkoutId}/discounts treats the discount array in the request body as the full, final set of manual discounts for that checkout, not an addition to what is already there. If the new request only contains the one discount you meant to add, BigCommerce clears out all existing discounts applied to line items, including product- and order-based discounts, so any earlier coupon or promotion is gone with no merge and no warning in the response.

Is it safe to just re-POST the merged discount list to fix an affected checkout?

Only if you can reliably reconstruct the original state, which is usually not the case. A coupon's validity window, its usage counters, and tax recalculation cannot be rebuilt client-side from what the checkout looks like after the fact. The safer default is to flag and report the affected checkout, and only re-apply corrections when a human has explicitly authorized it outside of dry run mode.

Where does the lost discount actually disappear, before or after the order is created?

Before. Checkout discounts operate on the pre-order checkout resource at /v3/checkouts/{checkoutId}, not the immutable /v2/orders/{id}. The loss happens upstream of order creation, so by the time the order is placed it already reflects the wrong total, and there is no audit trail on the order itself pointing back to the API call that caused it.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Checkout Discounts, including the replacement behavior. developer.bigcommerce.com checkout discounts
  2. BigCommerce Support Community: want to apply discount using api. support.bigcommerce.com want to apply discount using api
  3. BigCommerce Support Community: how to apply a coupon to an order using the api. support.bigcommerce.com how to apply a coupon to an order using the api

On the solution:

  1. BigCommerce Developer Center: Checkout Discounts reference. developer.bigcommerce.com checkout discounts
  2. BigCommerce Developer Center: Checkout Coupons reference. developer.bigcommerce.com checkout coupons
  3. BigCommerce Docs: Add Coupon to Checkout endpoint. docs.bigcommerce.com add checkout coupon

Stuck on a tricky one?

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

Contact me on LinkedIn

Did this save a customer's coupon?

If this caught a discount that would have silently disappeared, or saved you from a pile of support tickets about coupons that "did not work," 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