Diagnostic Coupons / Promotions
BigCommerce promotion with both group_ids and excluded_group_ids never triggers
The promotion saved fine. The API answered 200. But at checkout, nobody gets the discount, not even the VIP shoppers the merchant meant to include. BigCommerce's Promotions API lets a promotion's customer eligibility object carry both an allow-list of groups and a deny-list of groups at the same time, and when both are populated, the promotion engine has no defined rule for which one wins, so it fails closed for everyone. Here is why that combination is dead on arrival and a small script that finds every promotion carrying it.
A promotion's customer eligibility object can carry both group_ids (an allow-list) and excluded_group_ids (a deny-list). The Promotions API accepts and stores this combination without a validation error, since it only checks the shape of the request, not the business logic. But BigCommerce's own docs say only one of the two fields should be populated at a time. When both are non-empty, the eligibility check has no defined precedence between "must be in these groups" and "must not be in these groups," so it fails closed, and the promotion silently never triggers at checkout for any shopper. Run a small Python or Node.js script that calls GET /v3/promotions?status=ENABLED, flags every promotion where both lists are non-empty, and reports it with a suggested fix. It never auto-mutates unless you explicitly opt in.
The problem in plain words
A BigCommerce promotion's customer eligibility is expressed as an object that can include group_ids, the customer groups allowed to redeem it, and excluded_group_ids, the customer groups blocked from redeeming it. In the vast majority of promotions only one of these two is ever set, an allow-list for a VIP tier, or a deny-list to keep wholesale accounts out of a retail discount.
Nothing in the API stops a merchant, or a script built against the wrong mental model of the schema, from populating both at once. The PUT or POST to /v3/promotions is accepted, the response comes back 200 or 201, and the promotion looks completely normal in the admin. But the eligibility rule it now encodes is contradictory: "only these groups qualify" and "these groups are blocked" with no stated precedence between them when they both apply to the same request. BigCommerce's engine resolves that ambiguity by failing closed, treating the promotion as not eligible for anyone, rather than picking a side. The result is a promotion that reports zero redemptions, and because the write succeeded and the storefront failure is silent, most merchants only notice when someone points out the coupon never fires.
Why it happens
The Promotions API is a general-purpose rules engine, and its customer eligibility schema exposes both fields because each is valid on its own. A few common ways stores end up with both populated at the same time:
- A merchant starts a promotion as an allow-list for a VIP group, later decides to also exclude wholesale accounts "just in case," and adds
excluded_group_idswithout realizing the two rules now contradict each other for any customer in neither group and for any customer meant to be included. - A migration or bulk-import script copies eligibility data from another platform's promotion model, where an allow-list and a deny-list are legitimately combinable, and writes both fields into BigCommerce's schema without knowing BigCommerce leaves the interaction between them undefined.
- A promotion is cloned from a template that had
excluded_group_idsset for a different purpose, and a newgroup_idsallow-list is layered on top without clearing the old exclusion. - Per-rule
customerconditions nested insiderules[]are edited independently of the top-levelcustomerobject, so one rule ends up with an allow-list while another rule (or the top level) still carries a leftover deny-list.
Because the API's validation only checks that each field is a well-formed array of customer group IDs, none of these cases produce an error. The promotion looks correct in the admin UI and in the API response. It just never fires. See the citations at the end for the exact support threads and docs that describe this behavior.
A promotion record having both group_ids and excluded_group_ids non-empty is never a valid, intentional state, even though the API allows it. So the safe pattern is not "guess which list the merchant meant to keep and fix it automatically." It is "detect the conflict, report both lists exactly as stored, and let a human decide the intended semantics." A default suggested fix of clearing excluded_group_ids is offered because it preserves the narrower, more deliberate allow-list, but it is only ever applied when the merchant explicitly opts in.
The fix, as a flow
We do not touch the live promotions engine or checkout. We add a job that lists enabled promotions, inspects each one's customer eligibility object at the top level and inside every rule, and reports the ones with a conflicting allow-list and deny-list, with a suggested single-field fix a merchant can review and apply on their own terms.
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 Marketing (modify) scope so it can read and, if you opt in, update promotions. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" # start safe, change to false with --apply-clear-excluded to write
// 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 with --apply-clear-excluded to write
Talk to the V3 Promotions REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. The V3 API wraps list responses in {data, meta}, with pagination links under meta.pagination.links.next. A small helper handles GET and PUT and raises on a non-2xx response.
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()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
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}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List enabled promotions and pull their eligibility fields
Call GET /v3/promotions?status=ENABLED&limit=250, paginated through meta.pagination.links.next, to get every currently enabled promotion. For each item in data[], read the top-level customer.group_ids and customer.excluded_group_ids, plus the same pair nested inside each entry of rules[] if the promotion defines per-rule customer conditions.
def enabled_promotions():
params = {"status": "ENABLED", "limit": 250}
path = "/promotions"
while path:
payload = bc_get(path, params if path == "/promotions" else None)
for promo in payload.get("data", []):
yield promo
next_url = payload.get("meta", {}).get("pagination", {}).get("links", {}).get("next")
path = next_url.replace(API_BASE, "") if next_url else None
params = None
def eligibility_pairs(promotion):
"""Yield (scope_label, group_ids, excluded_group_ids) for the top level and each rule."""
customer = promotion.get("customer") or {}
yield ("top_level", customer.get("group_ids") or [], customer.get("excluded_group_ids") or [])
for i, rule in enumerate(promotion.get("rules") or []):
rule_customer = rule.get("customer") or {}
if rule_customer:
yield (f"rules[{i}]", rule_customer.get("group_ids") or [], rule_customer.get("excluded_group_ids") or [])
async function* enabledPromotions() {
let params = { status: "ENABLED", limit: 250 };
let path = "/promotions";
while (path) {
const payload = await bcGet(path, params || {});
for (const promo of payload.data || []) yield promo;
const nextUrl = payload.meta && payload.meta.pagination && payload.meta.pagination.links && payload.meta.pagination.links.next;
path = nextUrl ? nextUrl.replace(API_BASE, "") : null;
params = null;
}
}
function* eligibilityPairs(promotion) {
const customer = promotion.customer || {};
yield ["top_level", customer.group_ids || [], customer.excluded_group_ids || []];
const rules = promotion.rules || [];
for (let i = 0; i < rules.length; i++) {
const ruleCustomer = rules[i].customer || {};
if (rules[i].customer) {
yield [`rules[${i}]`, ruleCustomer.group_ids || [], ruleCustomer.excluded_group_ids || []];
}
}
}
Decide, with one pure function
Keep the decision in its own function that takes only group_ids and excluded_group_ids and returns a plain conflict verdict. Both empty is the valid all-customers case. Only one populated is a normal allow-list or deny-list. Both non-empty, including the guest sentinel group id 0 appearing in either list, is the conflict this whole job exists to catch.
from typing import Optional, TypedDict
class GroupConflictResult(TypedDict):
conflict: bool
reason: str
suggested_fix: Optional[dict]
def decide_group_conflict(group_ids: list, excluded_group_ids: list) -> GroupConflictResult:
if group_ids and excluded_group_ids:
return {
"conflict": True,
"reason": "both group_ids and excluded_group_ids populated",
"suggested_fix": {"clear": "excluded_group_ids"},
}
return {"conflict": False, "reason": "at most one of the two lists is populated", "suggested_fix": None}
export function decideGroupConflict(groupIds, excludedGroupIds) {
if ((groupIds || []).length > 0 && (excludedGroupIds || []).length > 0) {
return {
conflict: true,
reason: "both group_ids and excluded_group_ids populated",
suggestedFix: { clear: "excluded_group_ids" },
};
}
return { conflict: false, reason: "at most one of the two lists is populated", suggestedFix: null };
}
Report every conflict, never auto-mutate by default
For each flagged promotion, log its id, name, the scope the conflict was found in (top level or a specific rule), group_ids, and excluded_group_ids, plus the suggested fix. Only a human can tell whether the merchant meant to include VIPs or exclude wholesale accounts, so the default run under DRY_RUN=true only reports.
def report_conflict(promo, scope, group_ids, excluded_group_ids, result):
print(
f"CONFLICT id={promo['id']} name={promo.get('name')!r} scope={scope} "
f"group_ids={group_ids} excluded_group_ids={excluded_group_ids} "
f"suggested_fix={result['suggested_fix']}"
)
function reportConflict(promo, scope, groupIds, excludedGroupIds, result) {
console.log(
`CONFLICT id=${promo.id} name=${JSON.stringify(promo.name)} scope=${scope} ` +
`group_ids=${JSON.stringify(groupIds)} excluded_group_ids=${JSON.stringify(excludedGroupIds)} ` +
`suggested_fix=${JSON.stringify(result.suggestedFix)}`
);
}
Wire it together with an explicit opt-in remediation
The default run only flags. If a merchant explicitly runs with DRY_RUN=false --apply-clear-excluded, the top-level conflict can be repaired by issuing PUT /v3/promotions/{id} with the same payload minus excluded_group_ids (set to an empty list), then re-fetching via GET /v3/promotions/{id} to confirm only one of the two arrays is populated before marking the record resolved. This remediation only ever touches the top-level customer object; per-rule conflicts are always left for manual review because rule-level intent varies too much to guess safely.
Always start with DRY_RUN=true. Never let the job silently pick a side between an allow-list and a deny-list, since only the merchant knows whether the promotion was meant to include a VIP group or exclude a wholesale group. The clear-excluded-group-ids remediation is opt-in, non-default, and re-verified with a follow-up GET before being marked resolved.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every conflicting promotion it finds, respects the dry run flag, and only mutates a promotion when explicitly told to with the opt-in flag, always re-confirming the result with a follow-up read.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find BigCommerce promotions where group_ids and excluded_group_ids both fire.
A promotion's customer eligibility object can carry both group_ids (an allow-list
of customer group IDs) and excluded_group_ids (a deny-list). The Promotions API
accepts and stores this combination without a validation error, because it only
checks the shape of the request, not the business logic of the rule. BigCommerce's
own docs say only one of the two fields should be populated at a time. When both
are non-empty, the promotion engine's eligibility check has no defined precedence
between "must be in these groups" and "must not be in these groups," so it fails
closed and the promotion never triggers at checkout for any shopper, even ones who
satisfy group_ids. This job lists every ENABLED promotion, flags the ones with a
conflicting allow-list and deny-list at the top level or inside any rule, and only
ever reports the conflict with a suggested fix, unless explicitly told to apply the
opt-in clear-excluded-group-ids remediation. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/promotion-group-exclusion-conflict/
"""
import os
import sys
import logging
from typing import Optional, TypedDict
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_group_conflicts")
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"
APPLY_CLEAR_EXCLUDED = "--apply-clear-excluded" in sys.argv
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
class GroupConflictResult(TypedDict):
conflict: bool
reason: str
suggested_fix: Optional[dict]
def decide_group_conflict(group_ids: list, excluded_group_ids: list) -> GroupConflictResult:
"""Pure decision. No network, no side effects.
Both empty, or only one of the two lists populated: no conflict (a valid,
unambiguous eligibility rule, including the valid all-customers case).
Both non-empty, including the group_id 0 guest sentinel appearing in either
list: conflict, with a default suggested fix of clearing excluded_group_ids
to keep the narrower, more deliberate allow-list.
"""
if group_ids and excluded_group_ids:
return {
"conflict": True,
"reason": "both group_ids and excluded_group_ids populated",
"suggested_fix": {"clear": "excluded_group_ids"},
}
return {
"conflict": False,
"reason": "at most one of the two lists is populated",
"suggested_fix": None,
}
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()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def enabled_promotions():
"""Page through every ENABLED promotion via the {data, meta} envelope."""
params = {"status": "ENABLED", "limit": 250}
path = "/promotions"
while path:
payload = bc_get(path, params if path == "/promotions" else None)
for promo in payload.get("data", []):
yield promo
next_url = (
payload.get("meta", {}).get("pagination", {}).get("links", {}).get("next")
)
path = next_url.replace(API_BASE, "") if next_url else None
params = None
def eligibility_pairs(promotion):
"""Yield (scope_label, group_ids, excluded_group_ids) for top level and each rule."""
customer = promotion.get("customer") or {}
yield ("top_level", customer.get("group_ids") or [], customer.get("excluded_group_ids") or [])
for i, rule in enumerate(promotion.get("rules") or []):
rule_customer = rule.get("customer") or {}
if rule_customer:
yield (
f"rules[{i}]",
rule_customer.get("group_ids") or [],
rule_customer.get("excluded_group_ids") or [],
)
def apply_clear_excluded(promotion):
"""Opt-in remediation. Clears excluded_group_ids at the top level only,
then re-fetches to confirm only one array is populated before returning."""
promo_id = promotion["id"]
customer = dict(promotion.get("customer") or {})
customer["excluded_group_ids"] = []
bc_put(f"/promotions/{promo_id}", {"customer": customer})
refreshed = bc_get(f"/promotions/{promo_id}")
data = refreshed.get("data", refreshed)
fixed_customer = data.get("customer") or {}
still_conflicting = decide_group_conflict(
fixed_customer.get("group_ids") or [], fixed_customer.get("excluded_group_ids") or []
)["conflict"]
return not still_conflicting
def run():
flagged = 0
resolved = 0
for promo in enabled_promotions():
for scope, group_ids, excluded_group_ids in eligibility_pairs(promo):
result = decide_group_conflict(group_ids, excluded_group_ids)
if not result["conflict"]:
continue
flagged += 1
log.warning(
"CONFLICT id=%s name=%r scope=%s group_ids=%s excluded_group_ids=%s "
"suggested_fix=%s",
promo["id"], promo.get("name"), scope, group_ids, excluded_group_ids,
result["suggested_fix"],
)
if scope == "top_level" and not DRY_RUN and APPLY_CLEAR_EXCLUDED:
ok = apply_clear_excluded(promo)
if ok:
resolved += 1
log.info("RESOLVED id=%s cleared excluded_group_ids", promo["id"])
else:
log.error("STILL CONFLICTING id=%s after apply, needs manual review", promo["id"])
log.info(
"Done. %d conflict(s) flagged, %d resolved.",
flagged, resolved,
)
if __name__ == "__main__":
run()
/**
* Find BigCommerce promotions where group_ids and excluded_group_ids both fire.
*
* A promotion's customer eligibility object can carry both group_ids (an allow-list
* of customer group IDs) and excluded_group_ids (a deny-list). The Promotions API
* accepts and stores this combination without a validation error, because it only
* checks the shape of the request, not the business logic of the rule. BigCommerce's
* own docs say only one of the two fields should be populated at a time. When both
* are non-empty, the promotion engine's eligibility check has no defined precedence
* between "must be in these groups" and "must not be in these groups," so it fails
* closed and the promotion never triggers at checkout for any shopper, even ones who
* satisfy group_ids. This job lists every ENABLED promotion, flags the ones with a
* conflicting allow-list and deny-list at the top level or inside any rule, and only
* ever reports the conflict with a suggested fix, unless explicitly told to apply the
* opt-in clear-excluded-group-ids remediation.
*
* Guide: https://www.allanninal.dev/bigcommerce/promotion-group-exclusion-conflict/
*/
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 APPLY_CLEAR_EXCLUDED = process.argv.includes("--apply-clear-excluded");
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* Both empty, or only one of the two lists populated: no conflict (a valid,
* unambiguous eligibility rule, including the valid all-customers case).
* Both non-empty, including the group_id 0 guest sentinel appearing in either
* list: conflict, with a default suggested fix of clearing excluded_group_ids
* to keep the narrower, more deliberate allow-list.
*/
export function decideGroupConflict(groupIds, excludedGroupIds) {
const hasGroupIds = (groupIds || []).length > 0;
const hasExcludedGroupIds = (excludedGroupIds || []).length > 0;
if (hasGroupIds && hasExcludedGroupIds) {
return {
conflict: true,
reason: "both group_ids and excluded_group_ids populated",
suggestedFix: { clear: "excluded_group_ids" },
};
}
return {
conflict: false,
reason: "at most one of the two lists is populated",
suggestedFix: null,
};
}
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}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* enabledPromotions() {
let params = { status: "ENABLED", limit: 250 };
let path = "/promotions";
while (path) {
const payload = await bcGet(path, params || {});
for (const promo of payload.data || []) yield promo;
const nextUrl =
payload.meta && payload.meta.pagination && payload.meta.pagination.links && payload.meta.pagination.links.next;
path = nextUrl ? nextUrl.replace(API_BASE, "") : null;
params = null;
}
}
function* eligibilityPairs(promotion) {
const customer = promotion.customer || {};
yield ["top_level", customer.group_ids || [], customer.excluded_group_ids || []];
const rules = promotion.rules || [];
for (let i = 0; i < rules.length; i++) {
const ruleCustomer = rules[i].customer || {};
if (rules[i].customer) {
yield [`rules[${i}]`, ruleCustomer.group_ids || [], ruleCustomer.excluded_group_ids || []];
}
}
}
async function applyClearExcluded(promotion) {
const promoId = promotion.id;
const customer = { ...(promotion.customer || {}) };
customer.excluded_group_ids = [];
await bcPut(`/promotions/${promoId}`, { customer });
const refreshed = await bcGet(`/promotions/${promoId}`);
const data = refreshed.data || refreshed;
const fixedCustomer = data.customer || {};
const stillConflicting = decideGroupConflict(
fixedCustomer.group_ids || [],
fixedCustomer.excluded_group_ids || []
).conflict;
return !stillConflicting;
}
export async function run() {
let flagged = 0;
let resolved = 0;
for await (const promo of enabledPromotions()) {
for (const [scope, groupIds, excludedGroupIds] of eligibilityPairs(promo)) {
const result = decideGroupConflict(groupIds, excludedGroupIds);
if (!result.conflict) continue;
flagged += 1;
console.warn(
`CONFLICT id=${promo.id} name=${JSON.stringify(promo.name)} scope=${scope} ` +
`group_ids=${JSON.stringify(groupIds)} excluded_group_ids=${JSON.stringify(excludedGroupIds)} ` +
`suggested_fix=${JSON.stringify(result.suggestedFix)}`
);
if (scope === "top_level" && !DRY_RUN && APPLY_CLEAR_EXCLUDED) {
const ok = await applyClearExcluded(promo);
if (ok) {
resolved += 1;
console.log(`RESOLVED id=${promo.id} cleared excluded_group_ids`);
} else {
console.error(`STILL CONFLICTING id=${promo.id} after apply, needs manual review`);
}
}
}
}
console.log(`Done. ${flagged} conflict(s) flagged, ${resolved} resolved.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a real promotion gets flagged and what fix gets suggested. Because decide_group_conflict takes only two plain lists and returns a plain dict, the test needs no network and no BigCommerce store.
from find_group_conflicts import decide_group_conflict
def test_no_conflict_when_both_lists_are_empty():
result = decide_group_conflict([], [])
assert result["conflict"] is False
def test_no_conflict_when_only_group_ids_is_populated():
result = decide_group_conflict([12, 14], [])
assert result["conflict"] is False
def test_no_conflict_when_only_excluded_group_ids_is_populated():
result = decide_group_conflict([], [9])
assert result["conflict"] is False
def test_conflict_when_both_lists_are_populated():
result = decide_group_conflict([12, 14], [9])
assert result["conflict"] is True
assert result["reason"] == "both group_ids and excluded_group_ids populated"
assert result["suggested_fix"] == {"clear": "excluded_group_ids"}
def test_conflict_when_guest_sentinel_zero_is_in_group_ids():
result = decide_group_conflict([0], [9])
assert result["conflict"] is True
def test_conflict_when_guest_sentinel_zero_is_in_excluded_group_ids():
result = decide_group_conflict([12], [0])
assert result["conflict"] is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideGroupConflict } from "./find-group-conflicts.js";
test("no conflict when both lists are empty", () => {
const result = decideGroupConflict([], []);
assert.equal(result.conflict, false);
});
test("no conflict when only group_ids is populated", () => {
const result = decideGroupConflict([12, 14], []);
assert.equal(result.conflict, false);
});
test("no conflict when only excluded_group_ids is populated", () => {
const result = decideGroupConflict([], [9]);
assert.equal(result.conflict, false);
});
test("conflict when both lists are populated", () => {
const result = decideGroupConflict([12, 14], [9]);
assert.equal(result.conflict, true);
assert.equal(result.reason, "both group_ids and excluded_group_ids populated");
assert.deepEqual(result.suggestedFix, { clear: "excluded_group_ids" });
});
test("conflict when guest sentinel zero is in group_ids", () => {
const result = decideGroupConflict([0], [9]);
assert.equal(result.conflict, true);
});
test("conflict when guest sentinel zero is in excluded_group_ids", () => {
const result = decideGroupConflict([12], [0]);
assert.equal(result.conflict, true);
});
Case studies
A tiered loyalty discount that stopped firing after a "just in case" edit
A merchant launched a 15 percent discount scoped to their VIP customer group with group_ids. A few weeks later, a well-meaning admin added the store's wholesale group to excluded_group_ids, thinking it would double up the protection against wholesale accounts sneaking in. The promotion had never been reachable by wholesale accounts in the first place, since they were never in the VIP group. From that edit forward, redemptions dropped to zero, including for genuine VIP shoppers.
The script's next run flagged the promotion's top-level customer object immediately, showing both lists side by side. Once the merchant saw the VIP group id sitting in group_ids next to the wholesale group id in excluded_group_ids, the fix was obvious: clear the exclusion, since the allow-list already did the job.
A bulk import that carried over a combinable rule BigCommerce cannot resolve
During a platform migration, a script ported dozens of promotions into BigCommerce, translating each source promotion's eligibility rules field for field. The source platform supported an allow-list and a deny-list working together as a genuine intersection. Several imported promotions landed in BigCommerce with both group_ids and excluded_group_ids non-empty, and every one of them went live reporting zero redemptions.
Running the detection script across all ENABLED promotions surfaced the full list in one pass, each with its id, name, and both arrays. The migration team reviewed each case against the original source rule and applied the opt-in remediation only where clearing the exclusion matched the original intent, leaving the rest for a rule redesign.
After this runs on a schedule, no promotion sits enabled with a self-contradicting eligibility rule for more than one detection cycle without someone knowing about it. Every conflict is reported with both lists exactly as stored, plus a suggested fix, so a merchant can restore the intended behavior in seconds instead of discovering it weeks later through a zero-redemption report.
FAQ
Why does a BigCommerce promotion never trigger when it has both group_ids and excluded_group_ids set?
BigCommerce's Promotions API accepts and stores a customer eligibility object with both an allow-list (group_ids) and a deny-list (excluded_group_ids) populated, even though the docs say only one should be used at a time. With both non-empty, the promotion engine's eligibility check has no defined precedence between must be in these groups and must not be in these groups, so it fails closed and the promotion never triggers for any shopper, even ones who satisfy group_ids.
Why does the API let me save a promotion with both fields populated instead of rejecting it?
The Promotions API validates the shape of the request, not the business logic of the eligibility rule. A payload with both group_ids and excluded_group_ids as valid arrays of integers passes schema validation and returns HTTP 200 or 201, so the write succeeds silently. The conflict only shows up as a behavioral symptom at checkout, not as an API error.
Is it safe to auto-fix a promotion that has both group_ids and excluded_group_ids set?
No. Only a human can decide the intended eligibility semantics, whether the merchant meant to include a VIP group or exclude a wholesale group, so the script should flag the conflict and suggest a fix rather than silently mutate it. Clearing excluded_group_ids is offered as the default suggested fix because it keeps the narrower, more deliberate allow-list, but it must be applied as an explicit opt-in, never automatically.
Related field notes
Citations
On the problem:
- BigCommerce Support: excluding a customer group from a promotion. support.bigcommerce.com is it possible to exclude a customer group from a promotion
- BigCommerce Support: omitting a customer group from a coupon code. support.bigcommerce.com omit a customer group from using a coupon code
- BigCommerce Developer Center: customer promotion code samples. developer.bigcommerce.com customer promotion code samples
On the solution:
- BigCommerce Developer Center: the Promotions Single endpoint reference. developer.bigcommerce.com promotions single
- BigCommerce Developer Center: the Promotions API overview. developer.bigcommerce.com promotions API
- BigCommerce Developer Center: the Promotions API and customer segmentation. developer.bigcommerce.com promotions API customer segmentation
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or promotions 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 find your dead-on-arrival promotion?
If this saved you a support ticket or caught a conflict before a customer complained, 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