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.
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.
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:
- A promo automation posts a single discount object to
POST /v3/checkouts/{checkoutId}/discountsto add an incentive on top of whatever the customer already has, not realizing the array it sends becomes the entire discount set. - The integration was built and tested against empty carts, where there was nothing to lose, so the replacement behavior never surfaced until a real customer with a coupon already applied hit the same code path.
- A retry or webhook handler re-runs the same discount POST after a customer applies a coupon mid-session, clearing the coupon that was applied in between the first and second call.
- Nobody reads the discounts endpoint documentation closely enough to notice the specific wording, that calling the API clears out all existing discounts applied to line items, including product- and order-based discounts, because it reads like an ordinary write endpoint.
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.
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.
Build it step by step
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.
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
// 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
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.
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 {}
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) : {};
}
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.
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,
}
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,
};
}
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.
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,
}
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,
};
}
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.
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"],
}
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,
};
}
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.
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 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 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.
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
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
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.
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.
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:
- BigCommerce Developer Center: Checkout Discounts, including the replacement behavior. developer.bigcommerce.com checkout discounts
- BigCommerce Support Community: want to apply discount using api. support.bigcommerce.com want to apply discount using api
- 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:
- BigCommerce Developer Center: Checkout Discounts reference. developer.bigcommerce.com checkout discounts
- BigCommerce Developer Center: Checkout Coupons reference. developer.bigcommerce.com checkout coupons
- 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.
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