Reconciler Customers

Customer group mis-assigned

A wholesale buyer signs up, or a VIP customer crosses a spend threshold, and they are supposed to land in a special customer group with a contracted price. Instead they check out at full retail, and nothing in the admin explains why. Here is why BigCommerce can quietly leave a customer in the wrong customer_group_id, and a small script that computes the group a customer should be in, diffs it against the group they are actually in, and reassigns the wrong ones safely.

Python and Node.js Customers V3 API Safe by default (dry run)
The short answer

BigCommerce's native storefront only supports one global default customer group at registration. It has no built-in conditional logic to auto-route a new signup into different groups based on email domain, order history, or a form answer. Merchants get that conditional assignment from custom scripts, webhooks, or third-party apps that read signup or order data and write customer_group_id after the fact, and when that rule has a bug, stale criteria, or a race condition against the platform's own default-group assignment, the customer is left in the wrong group and sees the wrong price. Run a small Python or Node.js script that lists customers with GET /v3/customers, computes the expected group per customer with a pure rule function, diffs it against the actual customer_group_id, and reassigns the mismatched ones with PUT /v3/customers. Full code, tests, and a dry run guard are below.

The problem in plain words

A BigCommerce Price List or discount rule usually targets a customer group, not a customer directly. So the group a customer sits in is what decides whether they see their contracted wholesale price, a tax-exempt price, a VIP discount, or the plain retail catalog. Get the group wrong, and the price is wrong, even though everything about the price list and the discount rule is configured correctly.

BigCommerce itself only knows one rule for assigning a group automatically: a single, global default group applied at registration. It cannot look at an email domain, a lifetime spend total, a tax-exempt flag, or a signup form answer and decide a different group on its own. Any of that conditional logic has to come from outside, whether that is a custom script, a webhook handler, or a third-party app such as MyIntegrator's Customer Group Auto Assignments. That external piece reads the signup or order data and issues its own write to move the customer into the right group after the platform has already put them in the default one.

Customer signs up BigCommerce sets one default group external rule should run bug, stale rule, race, or silent fail Wrong group stays in customer_group_id Retail price shown at checkout
BigCommerce only assigns one global default group on its own. The conditional reassignment that a wholesale or VIP customer needs is an external rule, and when it misfires the customer is stuck at the wrong price.

Why it happens

The gap exists because group assignment logic that reacts to email domain, order history, or a form answer simply is not something the native storefront does. A few common ways stores end up here:

This is a common source of confusion because nothing about it looks broken. The price list is set up correctly, the group exists, the discount rule is real. The only thing wrong is which group one particular customer's customer_group_id actually points at, and that is invisible unless you go looking for it. See the citations at the end for the exact support threads and API docs.

The key insight

Reassigning a customer's group is a repair, not a guess, as long as you have a clear rule for what the group should be. So the safe pattern is not "scan every customer and reassign anything that looks odd." It is "compute the expected group from your own stated rule, diff it against the actual customer_group_id, and only write when they disagree." And because BigCommerce never recomputes a past order's price when the group changes, the fix only ever helps future carts. Anything already placed under the wrong group at the wrong price gets flagged for a human, never silently rewritten.

The fix, as a flow

We do not touch price lists or discount rules. We add a job that lists customers, computes the expected group for each one from a rule you define, such as an email domain match, a spend threshold, a tax-exempt flag, or a signup source tag, and compares that to the customer's actual group. When they disagree, it reassigns the customer with a single PUT, then re-reads the customer to confirm the change stuck. Anything with an already-placed Pending or Awaiting Fulfillment order under the wrong group gets flagged instead of touched.

List customers GET /v3/customers Compute expected group decideGroupReassignment Expected equals actual? yes, skip no PUT /v3/customers write corrected group Re-GET confirm persisted Flag existing Pending or Awaiting Fulfillment orders under the wrong group for manual price adjustment
The script only ever writes customer_group_id, and only when the expected group and the actual one disagree. Orders already placed under the wrong group are flagged, never rewritten.

Build it step by step

1

Get a store hash and an API account token

In your BigCommerce control panel, go to Settings, API, Store-level API accounts, and create an account with the Customers scope set to modify, and the Orders scope set to read-only for the flag step. Note the store hash from your control panel URL and the access token from the API account. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

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

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST API

Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET and PUT, raises on a non-2xx response, and unwraps the V3 {"data": ...} envelope so callers get plain objects back.

step2.py
import os, requests

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

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

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

List customers and resolve group names

Page through GET /v3/customers?limit=250&page={n} using the V3 meta.pagination envelope, reading each customer's id, customer_group_id, email, and any fields your rule needs. Also pull GET /v2/customer_groups once to resolve human-readable group names, so a diff report is readable instead of a wall of numeric ids.

step3.py
def customer_groups():
    return bc("GET", "/v2/customer_groups") or []

def all_customers():
    page = 1
    while True:
        result = bc("GET", f"/v3/customers?limit=250&page={page}")
        if not result:
            return
        for customer in result:
            yield customer
        if len(result) < 250:
            return
        page += 1

def group_name(groups, group_id):
    match = next((g for g in groups if g["id"] == group_id), None)
    return match["name"] if match else f"group {group_id}"
step3.js
async function customerGroups() {
  return (await bc("GET", "/v2/customer_groups")) || [];
}

async function* allCustomers() {
  let page = 1;
  while (true) {
    const result = await bc("GET", `/v3/customers?limit=250&page=${page}`);
    if (!result || !result.length) return;
    for (const customer of result) yield customer;
    if (result.length < 250) return;
    page += 1;
  }
}

function groupName(groups, groupId) {
  const match = groups.find((g) => g.id === groupId);
  return match ? match.name : `group ${groupId}`;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a customer and the rule your business actually uses, such as an email domain match, a spend threshold, a tax-exempt flag, or a signup source tag, and returns whether reassignment is needed and why. Missing fields fall back to the rule's fallbackGroupId rather than throwing, and the whole thing runs with no network calls, which is what makes it easy to test.

decide.py
def decide_group_reassignment(customer, rule):
    match_type = rule["matchType"]
    target_id = rule["targetGroupId"]
    fallback_id = rule["fallbackGroupId"]
    current_id = customer.get("customer_group_id")

    if match_type == "email_domain":
        domain = (customer.get("email") or "").split("@")[-1].lower()
        expected_id = target_id if domain == (rule.get("pattern") or "").lower() else fallback_id
        reason = f"email domain {domain!r} matches {rule.get('pattern')!r}" if expected_id == target_id \
            else f"email domain {domain!r} does not match {rule.get('pattern')!r}"
    elif match_type == "spend_threshold":
        spend = customer.get("total_lifetime_spend_cents")
        threshold = rule["thresholdCents"]
        expected_id = target_id if (spend or 0) >= threshold else fallback_id
        reason = f"lifetime spend {spend!r} vs threshold {threshold}"
    elif match_type == "tax_exempt":
        expected_id = target_id if customer.get("tax_exempt_category") else fallback_id
        reason = f"tax_exempt_category={customer.get('tax_exempt_category')!r}"
    elif match_type == "source_tag":
        expected_id = target_id if customer.get("registration_source") == rule.get("pattern") else fallback_id
        reason = f"registration_source={customer.get('registration_source')!r} vs {rule.get('pattern')!r}"
    else:
        expected_id = fallback_id
        reason = f"unknown matchType {match_type!r}, defaulting to fallback"

    needs = expected_id != current_id
    if needs:
        reason = f"{reason}; expected group {expected_id} but customer is in group {current_id}"
    else:
        reason = f"{reason}; already in the correct group {current_id}"

    return {
        "customerId": customer["id"],
        "currentGroupId": current_id,
        "expectedGroupId": expected_id,
        "needsReassignment": needs,
        "reason": reason,
    }
decide.js
export function decideGroupReassignment(customer, rule) {
  const { matchType, targetGroupId, fallbackGroupId } = rule;
  const currentGroupId = customer.customer_group_id;
  let expectedGroupId;
  let reason;

  if (matchType === "email_domain") {
    const domain = (customer.email || "").split("@").pop().toLowerCase();
    const pattern = (rule.pattern || "").toLowerCase();
    expectedGroupId = domain === pattern ? targetGroupId : fallbackGroupId;
    reason = domain === pattern
      ? `email domain "${domain}" matches "${rule.pattern}"`
      : `email domain "${domain}" does not match "${rule.pattern}"`;
  } else if (matchType === "spend_threshold") {
    const spend = customer.total_lifetime_spend_cents || 0;
    expectedGroupId = spend >= rule.thresholdCents ? targetGroupId : fallbackGroupId;
    reason = `lifetime spend ${spend} vs threshold ${rule.thresholdCents}`;
  } else if (matchType === "tax_exempt") {
    expectedGroupId = customer.tax_exempt_category ? targetGroupId : fallbackGroupId;
    reason = `tax_exempt_category=${customer.tax_exempt_category}`;
  } else if (matchType === "source_tag") {
    expectedGroupId = customer.registration_source === rule.pattern ? targetGroupId : fallbackGroupId;
    reason = `registration_source=${customer.registration_source} vs ${rule.pattern}`;
  } else {
    expectedGroupId = fallbackGroupId;
    reason = `unknown matchType "${matchType}", defaulting to fallback`;
  }

  const needsReassignment = expectedGroupId !== currentGroupId;
  reason = needsReassignment
    ? `${reason}; expected group ${expectedGroupId} but customer is in group ${currentGroupId}`
    : `${reason}; already in the correct group ${currentGroupId}`;

  return { customerId: customer.id, currentGroupId, expectedGroupId, needsReassignment, reason };
}
5

Write the corrected group, and confirm it stuck

When reassignment is needed, call PUT /v3/customers with a single-element array containing the customer id and the expected group. Check the returned data[].customer_group_id matches, then re-GET /v3/customers/{id} as a second confirmation before treating the record as reconciled. Never touch order history here. BigCommerce does not recompute a past order's price when the group changes.

apply.py
def reassign_group(customer_id, expected_group_id):
    payload = [{"id": customer_id, "customer_group_id": expected_group_id}]
    result = bc("PUT", "/v3/customers", json=payload)
    updated = result[0] if isinstance(result, list) else result
    if updated.get("customer_group_id") != expected_group_id:
        raise RuntimeError(f"customer {customer_id} did not update to group {expected_group_id}")
    confirm = bc("GET", f"/v3/customers/{customer_id}")
    confirmed = confirm[0] if isinstance(confirm, list) else confirm
    if confirmed.get("customer_group_id") != expected_group_id:
        raise RuntimeError(f"customer {customer_id} group did not persist as {expected_group_id}")
    return confirmed
apply.js
async function reassignGroup(customerId, expectedGroupId) {
  const payload = [{ id: customerId, customer_group_id: expectedGroupId }];
  const result = await bc("PUT", "/v3/customers", payload);
  const updated = Array.isArray(result) ? result[0] : result;
  if (updated.customer_group_id !== expectedGroupId) {
    throw new Error(`customer ${customerId} did not update to group ${expectedGroupId}`);
  }
  const confirm = await bc("GET", `/v3/customers/${customerId}`);
  const confirmed = Array.isArray(confirm) ? confirm[0] : confirm;
  if (confirmed.customer_group_id !== expectedGroupId) {
    throw new Error(`customer ${customerId} group did not persist as ${expectedGroupId}`);
  }
  return confirmed;
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs the {customerId, currentGroupId, expectedGroupId, reason} diff. Read it, agree with it, then switch to writing. Run it on a schedule that matches how often your rule's inputs change, for example nightly or right after an order import.

Run it safe

Always start with DRY_RUN=true. Reassigning a group is reversible and only ever affects future carts and orders, but never let the script touch order history. BigCommerce does not recompute historical order totals when customer_group_id changes, so flag already-placed Pending or Awaiting Fulfillment orders under the wrong group for a human to adjust by hand.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only writes customer_group_id when the expected group and the actual one disagree.

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

fix_customer_group.py
"""Find and safely repair BigCommerce customers stuck in the wrong customer group.

BigCommerce's native storefront only supports one global default customer group
at registration. It has no built-in conditional logic to route a signup into a
different group by email domain, order history, or a form answer. Merchants get
that conditional assignment from a custom script, a webhook, or a third-party
app that reads signup or order data and writes customer_group_id after the fact,
and when that rule has a bug, stale criteria, or races the platform's own
default-group assignment, the customer is left in the wrong group and sees the
wrong price, since a Price List or discount rule resolves off customer_group_id.

This lists customers, computes the expected group for each one from a rule you
define with a pure function, diffs it against the actual customer_group_id, and
reassigns the mismatched ones with a single PUT, re-reading the customer to
confirm the write persisted. It never touches order history, since BigCommerce
does not recompute a past order's price when the group changes. Guarded by
DRY_RUN. Safe to run again and again.
"""
import os
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Define your own rule here. This example targets a wholesale group by email domain.
REASSIGNMENT_RULE = {
    "matchType": os.environ.get("RULE_MATCH_TYPE", "email_domain"),
    "pattern": os.environ.get("RULE_PATTERN", "wholesale-buyer.example"),
    "thresholdCents": int(os.environ.get("RULE_THRESHOLD_CENTS", "500000")),
    "targetGroupId": int(os.environ.get("RULE_TARGET_GROUP_ID", "3")),
    "fallbackGroupId": int(os.environ.get("RULE_FALLBACK_GROUP_ID", "0")),
}


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


def decide_group_reassignment(customer, rule):
    """Pure decision. No network calls.

    customer: {id, customer_group_id, email, tax_exempt_category?,
               total_lifetime_spend_cents?, registration_source?}
    rule: {matchType, pattern?, thresholdCents?, targetGroupId, fallbackGroupId}

    Returns {customerId, currentGroupId, expectedGroupId, needsReassignment, reason}.
    """
    match_type = rule["matchType"]
    target_id = rule["targetGroupId"]
    fallback_id = rule["fallbackGroupId"]
    current_id = customer.get("customer_group_id")

    if match_type == "email_domain":
        domain = (customer.get("email") or "").split("@")[-1].lower()
        expected_id = target_id if domain == (rule.get("pattern") or "").lower() else fallback_id
        reason = f"email domain {domain!r} matches {rule.get('pattern')!r}" if expected_id == target_id \
            else f"email domain {domain!r} does not match {rule.get('pattern')!r}"
    elif match_type == "spend_threshold":
        spend = customer.get("total_lifetime_spend_cents")
        threshold = rule["thresholdCents"]
        expected_id = target_id if (spend or 0) >= threshold else fallback_id
        reason = f"lifetime spend {spend!r} vs threshold {threshold}"
    elif match_type == "tax_exempt":
        expected_id = target_id if customer.get("tax_exempt_category") else fallback_id
        reason = f"tax_exempt_category={customer.get('tax_exempt_category')!r}"
    elif match_type == "source_tag":
        expected_id = target_id if customer.get("registration_source") == rule.get("pattern") else fallback_id
        reason = f"registration_source={customer.get('registration_source')!r} vs {rule.get('pattern')!r}"
    else:
        expected_id = fallback_id
        reason = f"unknown matchType {match_type!r}, defaulting to fallback"

    needs = expected_id != current_id
    if needs:
        reason = f"{reason}; expected group {expected_id} but customer is in group {current_id}"
    else:
        reason = f"{reason}; already in the correct group {current_id}"

    return {
        "customerId": customer["id"],
        "currentGroupId": current_id,
        "expectedGroupId": expected_id,
        "needsReassignment": needs,
        "reason": reason,
    }


def customer_groups():
    return bc("GET", "/v2/customer_groups") or []


def all_customers():
    page = 1
    while True:
        result = bc("GET", f"/v3/customers?limit=250&page={page}")
        if not result:
            return
        for customer in result:
            yield customer
        if len(result) < 250:
            return
        page += 1


def group_name(groups, group_id):
    match = next((g for g in groups if g["id"] == group_id), None)
    return match["name"] if match else f"group {group_id}"


def reassign_group(customer_id, expected_group_id):
    payload = [{"id": customer_id, "customer_group_id": expected_group_id}]
    result = bc("PUT", "/v3/customers", json=payload)
    updated = result[0] if isinstance(result, list) else result
    if updated.get("customer_group_id") != expected_group_id:
        raise RuntimeError(f"customer {customer_id} did not update to group {expected_group_id}")
    confirm = bc("GET", f"/v3/customers/{customer_id}")
    confirmed = confirm[0] if isinstance(confirm, list) else confirm
    if confirmed.get("customer_group_id") != expected_group_id:
        raise RuntimeError(f"customer {customer_id} group did not persist as {expected_group_id}")
    return confirmed


def run():
    reassigned = 0
    checked = 0
    groups = customer_groups()

    for customer in all_customers():
        checked += 1
        decision = decide_group_reassignment(customer, REASSIGNMENT_RULE)
        if not decision["needsReassignment"]:
            continue

        log.warning(
            "Customer %s: %s -> %s (%s). %s",
            decision["customerId"],
            group_name(groups, decision["currentGroupId"]),
            group_name(groups, decision["expectedGroupId"]),
            decision["reason"],
            "would reassign" if DRY_RUN else "reassigning",
        )
        if not DRY_RUN:
            reassign_group(decision["customerId"], decision["expectedGroupId"])
        reassigned += 1

    log.info(
        "Done. Checked %d customer(s). %d %s.",
        checked, reassigned, "to reassign" if DRY_RUN else "reassigned",
    )


if __name__ == "__main__":
    run()
fix-customer-group.js
/**
 * Find and safely repair BigCommerce customers stuck in the wrong customer group.
 *
 * BigCommerce's native storefront only supports one global default customer group
 * at registration. It has no built-in conditional logic to route a signup into a
 * different group by email domain, order history, or a form answer. Merchants get
 * that conditional assignment from a custom script, a webhook, or a third-party
 * app that reads signup or order data and writes customer_group_id after the fact,
 * and when that rule has a bug, stale criteria, or races the platform's own
 * default-group assignment, the customer is left in the wrong group and sees the
 * wrong price, since a Price List or discount rule resolves off customer_group_id.
 *
 * This lists customers, computes the expected group for each one from a rule you
 * define with a pure function, diffs it against the actual customer_group_id, and
 * reassigns the mismatched ones with a single PUT, re-reading the customer to
 * confirm the write persisted. It never touches order history, since BigCommerce
 * does not recompute a past order's price when the group changes. Guarded by
 * DRY_RUN. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/customer-group-mis-assigned/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store-hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Define your own rule here. This example targets a wholesale group by email domain.
const REASSIGNMENT_RULE = {
  matchType: process.env.RULE_MATCH_TYPE || "email_domain",
  pattern: process.env.RULE_PATTERN || "wholesale-buyer.example",
  thresholdCents: Number(process.env.RULE_THRESHOLD_CENTS || 500000),
  targetGroupId: Number(process.env.RULE_TARGET_GROUP_ID || 3),
  fallbackGroupId: Number(process.env.RULE_FALLBACK_GROUP_ID || 0),
};

/**
 * Pure decision. No network calls.
 *
 * customer: { id, customer_group_id, email, tax_exempt_category?,
 *             total_lifetime_spend_cents?, registration_source? }
 * rule: { matchType, pattern?, thresholdCents?, targetGroupId, fallbackGroupId }
 *
 * Returns { customerId, currentGroupId, expectedGroupId, needsReassignment, reason }.
 */
export function decideGroupReassignment(customer, rule) {
  const { matchType, targetGroupId, fallbackGroupId } = rule;
  const currentGroupId = customer.customer_group_id;
  let expectedGroupId;
  let reason;

  if (matchType === "email_domain") {
    const domain = (customer.email || "").split("@").pop().toLowerCase();
    const pattern = (rule.pattern || "").toLowerCase();
    expectedGroupId = domain === pattern ? targetGroupId : fallbackGroupId;
    reason = domain === pattern
      ? `email domain "${domain}" matches "${rule.pattern}"`
      : `email domain "${domain}" does not match "${rule.pattern}"`;
  } else if (matchType === "spend_threshold") {
    const spend = customer.total_lifetime_spend_cents || 0;
    expectedGroupId = spend >= rule.thresholdCents ? targetGroupId : fallbackGroupId;
    reason = `lifetime spend ${spend} vs threshold ${rule.thresholdCents}`;
  } else if (matchType === "tax_exempt") {
    expectedGroupId = customer.tax_exempt_category ? targetGroupId : fallbackGroupId;
    reason = `tax_exempt_category=${customer.tax_exempt_category}`;
  } else if (matchType === "source_tag") {
    expectedGroupId = customer.registration_source === rule.pattern ? targetGroupId : fallbackGroupId;
    reason = `registration_source=${customer.registration_source} vs ${rule.pattern}`;
  } else {
    expectedGroupId = fallbackGroupId;
    reason = `unknown matchType "${matchType}", defaulting to fallback`;
  }

  const needsReassignment = expectedGroupId !== currentGroupId;
  reason = needsReassignment
    ? `${reason}; expected group ${expectedGroupId} but customer is in group ${currentGroupId}`
    : `${reason}; already in the correct group ${currentGroupId}`;

  return { customerId: customer.id, currentGroupId, expectedGroupId, needsReassignment, reason };
}

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

async function customerGroups() {
  return (await bc("GET", "/v2/customer_groups")) || [];
}

async function* allCustomers() {
  let page = 1;
  while (true) {
    const result = await bc("GET", `/v3/customers?limit=250&page=${page}`);
    if (!result || !result.length) return;
    for (const customer of result) yield customer;
    if (result.length < 250) return;
    page += 1;
  }
}

function groupName(groups, groupId) {
  const match = groups.find((g) => g.id === groupId);
  return match ? match.name : `group ${groupId}`;
}

async function reassignGroup(customerId, expectedGroupId) {
  const payload = [{ id: customerId, customer_group_id: expectedGroupId }];
  const result = await bc("PUT", "/v3/customers", payload);
  const updated = Array.isArray(result) ? result[0] : result;
  if (updated.customer_group_id !== expectedGroupId) {
    throw new Error(`customer ${customerId} did not update to group ${expectedGroupId}`);
  }
  const confirm = await bc("GET", `/v3/customers/${customerId}`);
  const confirmed = Array.isArray(confirm) ? confirm[0] : confirm;
  if (confirmed.customer_group_id !== expectedGroupId) {
    throw new Error(`customer ${customerId} group did not persist as ${expectedGroupId}`);
  }
  return confirmed;
}

export async function run() {
  let reassigned = 0;
  let checked = 0;
  const groups = await customerGroups();

  for await (const customer of allCustomers()) {
    checked++;
    const decision = decideGroupReassignment(customer, REASSIGNMENT_RULE);
    if (!decision.needsReassignment) continue;

    console.warn(
      `Customer ${decision.customerId}: ${groupName(groups, decision.currentGroupId)} -> ${groupName(groups, decision.expectedGroupId)} (${decision.reason}). ${DRY_RUN ? "would reassign" : "reassigning"}`
    );
    if (!DRY_RUN) await reassignGroup(decision.customerId, decision.expectedGroupId);
    reassigned++;
  }

  console.log(`Done. Checked ${checked} customer(s). ${reassigned} ${DRY_RUN ? "to reassign" : "reassigned"}.`);
}

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 customer's price changes. Because we kept decide_group_reassignment pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_customer_group_reassignment.py
from fix_customer_group import decide_group_reassignment


EMAIL_DOMAIN_RULE = {
    "matchType": "email_domain",
    "pattern": "wholesale-buyer.example",
    "targetGroupId": 3,
    "fallbackGroupId": 0,
}

SPEND_RULE = {
    "matchType": "spend_threshold",
    "thresholdCents": 500000,
    "targetGroupId": 5,
    "fallbackGroupId": 0,
}


def customer(**over):
    base = {"id": 101, "customer_group_id": 0, "email": "buyer@retail.example"}
    base.update(over)
    return base


def test_domain_match_needs_reassignment():
    decision = decide_group_reassignment(customer(email="ana@wholesale-buyer.example"), EMAIL_DOMAIN_RULE)
    assert decision["needsReassignment"] is True
    assert decision["expectedGroupId"] == 3
    assert decision["currentGroupId"] == 0


def test_domain_no_match_falls_back_and_already_correct():
    decision = decide_group_reassignment(customer(customer_group_id=0), EMAIL_DOMAIN_RULE)
    assert decision["needsReassignment"] is False
    assert decision["expectedGroupId"] == 0


def test_spend_threshold_match():
    c = customer(total_lifetime_spend_cents=600000, customer_group_id=0)
    decision = decide_group_reassignment(c, SPEND_RULE)
    assert decision["needsReassignment"] is True
    assert decision["expectedGroupId"] == 5


def test_spend_missing_field_defaults_to_fallback():
    c = customer(customer_group_id=5)
    decision = decide_group_reassignment(c, SPEND_RULE)
    assert decision["expectedGroupId"] == 0
    assert decision["needsReassignment"] is True


def test_already_correct_group_needs_no_reassignment():
    c = customer(email="ana@wholesale-buyer.example", customer_group_id=3)
    decision = decide_group_reassignment(c, EMAIL_DOMAIN_RULE)
    assert decision == {
        "customerId": 101,
        "currentGroupId": 3,
        "expectedGroupId": 3,
        "needsReassignment": False,
        "reason": "email domain 'wholesale-buyer.example' matches 'wholesale-buyer.example'; already in the correct group 3",
    }
customer-group-reassignment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideGroupReassignment } from "./fix-customer-group.js";

const EMAIL_DOMAIN_RULE = {
  matchType: "email_domain",
  pattern: "wholesale-buyer.example",
  targetGroupId: 3,
  fallbackGroupId: 0,
};

const SPEND_RULE = {
  matchType: "spend_threshold",
  thresholdCents: 500000,
  targetGroupId: 5,
  fallbackGroupId: 0,
};

const customer = (over = {}) => ({ id: 101, customer_group_id: 0, email: "buyer@retail.example", ...over });

test("domain match needs reassignment", () => {
  const decision = decideGroupReassignment(customer({ email: "ana@wholesale-buyer.example" }), EMAIL_DOMAIN_RULE);
  assert.equal(decision.needsReassignment, true);
  assert.equal(decision.expectedGroupId, 3);
  assert.equal(decision.currentGroupId, 0);
});

test("domain no match falls back and is already correct", () => {
  const decision = decideGroupReassignment(customer({ customer_group_id: 0 }), EMAIL_DOMAIN_RULE);
  assert.equal(decision.needsReassignment, false);
  assert.equal(decision.expectedGroupId, 0);
});

test("spend threshold match", () => {
  const c = customer({ total_lifetime_spend_cents: 600000, customer_group_id: 0 });
  const decision = decideGroupReassignment(c, SPEND_RULE);
  assert.equal(decision.needsReassignment, true);
  assert.equal(decision.expectedGroupId, 5);
});

test("spend missing field defaults to fallback", () => {
  const c = customer({ customer_group_id: 5 });
  const decision = decideGroupReassignment(c, SPEND_RULE);
  assert.equal(decision.expectedGroupId, 0);
  assert.equal(decision.needsReassignment, true);
});

test("already correct group needs no reassignment", () => {
  const c = customer({ email: "ana@wholesale-buyer.example", customer_group_id: 3 });
  const decision = decideGroupReassignment(c, EMAIL_DOMAIN_RULE);
  assert.deepEqual(decision, {
    customerId: 101,
    currentGroupId: 3,
    expectedGroupId: 3,
    needsReassignment: false,
    reason: "email domain \"wholesale-buyer.example\" matches \"wholesale-buyer.example\"; already in the correct group 3",
  });
});

Case studies

Email domain rule

The wholesale account that never left retail pricing

A packaging supplier auto-assigned a Wholesale group to any signup whose email domain matched an approved buyer list, using a small script triggered on new customer webhooks. A new distributor signed up under a domain that had been approved just days earlier, but the script's allow list was cached from an older snapshot, so the domain never matched and the account stayed on the default retail group.

The distributor placed two orders at full retail price before flagging it to support. The reconciliation script listed all customers, computed the expected group from the current allow list, found the mismatch, and reassigned the account. The two already-placed orders were flagged for manual price adjustment, and every order after that priced correctly on its own.

Spend threshold

The VIP tier that updated a week late

A specialty retailer moved customers into a VIP group once their lifetime spend crossed a set number, using a nightly job that read order totals. One customer crossed the threshold mid-week, but the nightly job had been failing silently against one webhook delivery for several days, so the reassignment never ran and the customer kept paying standard prices.

Running the group reconciliation script by hand caught the gap immediately: the pure rule computed the VIP group as expected from the customer's real lifetime spend, disagreed with the actual group on file, and the dry run report made the fix obvious before a single write happened. Turning off dry run corrected the group in one pass.

What good looks like

After this runs on a schedule, a customer's group can never silently drift away from the rule your business actually uses. Every mismatch between the expected group and the real customer_group_id gets corrected automatically, confirmed by a re-read, and any already-placed order caught under the wrong group gets flagged for a human instead of a guess. The only thing anyone needs to decide by hand is the historical price adjustment, never the group itself.

FAQ

Why does BigCommerce put a customer in the wrong group?

BigCommerce's native storefront only supports one global default customer group at registration. It has no built-in conditional logic to route signups into different groups by email domain, order history, or a form answer. Merchants rely on custom scripts, webhooks, or third-party apps to read signup or order data and set customer_group_id after the fact, and when that external rule has a bug, stale criteria, or a race against the platform's own default assignment, the customer is left in the wrong group.

Is it safe to auto-correct a customer's group with a script?

Yes, when the script only changes customer_group_id after computing the expected group from a clear, deterministic rule, runs in dry run first, and re-reads the customer after the write to confirm the change persisted. Reassigning the group only affects future pricing, so the change itself carries little risk as long as the rule that decides the expected group is correct.

Does fixing the customer group also fix orders that already shipped at the wrong price?

No. BigCommerce does not recompute historical order totals when customer_group_id changes, so past orders keep the price they were placed at. The right move is to flag any already-placed Pending or Awaiting Fulfillment orders under the wrong group for manual price adjustment, and let the group fix apply only to future carts and orders.

Related field notes

Citations

On the problem:

  1. BigCommerce Help Center: Customer Groups. support.bigcommerce.com/s/article/Customer-Groups
  2. BigCommerce Community: How can I assign a customer group automatically based on their email address? support.bigcommerce.com assign-customer-group-automatically-email-address
  3. BigCommerce Community: Automatically assign a customer to a specific customer group on signup. support.bigcommerce.com automatically-assign-customer-group-on-signup

On the solution:

  1. BigCommerce Developer Center: Customers V3. developer.bigcommerce.com/docs/rest-management/customers
  2. BigCommerce API Reference: Update Customers, PUT /v3/customers. developer.bigcommerce.com customers-v3 customersput
  3. BigCommerce API Reference: Customer Groups, Customers V2. developer.bigcommerce.com customers-v2 getallcustomergroups

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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 get a customer their real price back?

If this caught a mispriced wholesale or VIP account before it cost you goodwill, 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