Reconciler Customers

BigCommerce customers filter by id is rejected as unsupported

A script written against BigCommerce's v3 conventions calls GET /v2/customers?id=123 expecting the same id filter that works on v3, and gets back a 400: "The field 'id' is not supported by this resource." The customer record is fine. The v2 Customers list endpoint just never implemented id as a filterable field. Here is why that gap exists and a small script that reconciles the lookup through the direct resource path or migrates it to v3 outright.

Python and Node.js BigCommerce V2 and V3 Customers API Safe by default (dry run)
Man with beard and tattoos pushing a bicycle
Photo by Caden Norcott on Unsplash
The short answer

BigCommerce's v2 Customers resource (GET /v2/customers) only accepts a fixed, documented set of filter query params, email, name, company, date_created, and so on. id was never implemented as a filterable field on that legacy endpoint, unlike the v3 Customers API, which supports the id:in=1,2,3 filter syntax natively. Scripts and SDKs that assume v3-style filters work uniformly across versions pass ?id=123 to v2 and get a 400. Run a small Python or Node.js script that catches that specific 400, reconciles a single id through the direct resource path GET /v2/customers/{id}, or migrates the whole lookup to GET /v3/customers?id:in=1,2,3 when more than one id is involved. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce runs two generations of its Customers API side by side. The v3 Customers API is modern and consistent with the rest of v3: it wraps responses in {data, meta.pagination} and supports the id:in filter syntax so you can fetch a batch of customers by id in one call, GET /v3/customers?id:in=1,2,3. The older v2 Customers resource predates that convention. It has its own fixed whitelist of query params it will accept on the list endpoint, email, name, company, date_created, among a handful of others, and id simply is not on that list.

Any script, SDK, or hand-rolled client that assumes filter conventions are uniform across BigCommerce API versions, and there is a real, cited example of exactly this in the official bigcommerce-api-php client, ends up calling getCustomers style helpers with an id filter that gets forwarded straight to GET /v2/customers?id=123. BigCommerce rejects it outright with a 400, before it ever looks at whether a customer with that id exists.

Script or SDK assumes v3 filters GET /v2/customers ?id=123 id not in whitelist HTTP 400 field not supported Lookup blocked
The customer record is not missing. The v2 Customers list endpoint's filter whitelist simply omits id, so the query itself is rejected before any lookup happens.

Why it happens

The v2 Customers resource documents a fixed set of query params it will accept on GET /v2/customers, things like email, name, company, and date_created. It is a list endpoint that filters, not an id lookup endpoint. A few things line up to make this a common surprise:

See the citations at the end for the exact GitHub issue and BigCommerce's own documentation on both API versions.

The key insight

A 400 with "field is not supported" is a query-shape bug, not evidence about the data. The signature to look for is a 400 on the v2 filtered list call paired with a 200 on the direct resource path or the v3 equivalent, that combination means the customer exists and the calling code is simply asking the wrong endpoint the wrong way. Cross-check with GET /v3/customers?id:in=123, which succeeds and returns {data, meta.pagination}, before assuming anything is actually broken in the store.

The fix, as a flow

We do not change any customer data. We add a small reconciler that observes the version, the query, and the response, and decides whether the original v2 list-filter call was fine, needs a one-off fallback to the direct resource path, or should be migrated to the v3 batched id filter.

Lookup call version, query, status resolve_customer_lookup pure decision function v2 400 on field id? no, v3 or 200 ok_list_filter no change needed yes, single id fallback_direct_resource yes, multiple ids migrate_to_v3
The decision is deterministic: v3 always works as is, and a v2 400 on the id field falls back to the direct resource path for a single id, or migrates to the v3 batched id filter for multiple ids.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Customers (read-only is enough for lookups) scope. 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.

setup (shell)
pip install requests

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

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to write logs of migration plans
2

Talk to both the v2 and v3 Customers REST APIs

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with the token in the X-Auth-Token header. A small helper handles GET and never raises on a 400, it returns the status and parsed body so the caller can inspect the error field itself, since a 400 here is expected input to the decision, not a failure to crash on.

step2.py
import os, requests

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

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

def bc_get(path, params=None):
    r = requests.get(f"{API_ROOT}{path}", headers=HEADERS, params=params or {}, timeout=30)
    body = r.json() if r.text else {}
    return r.status_code, body
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_ROOT = `https://api.bigcommerce.com/stores/${STORE_HASH}`;

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

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

Attempt the v2 filtered list call and read the error field

Call GET /v2/customers?id=123 with the ids you have. When BigCommerce rejects it, the body looks like {"error":"The field 'id' is not supported by this resource."}. Pull the field name back out of that message so the decision function has a plain string to work with instead of parsing prose every time.

step3.py
import re

FIELD_NOT_SUPPORTED = re.compile(r"field '(\w+)' is not supported", re.IGNORECASE)

def try_v2_id_filter(ids):
    status, body = bc_get("/v2/customers", {"id": ",".join(str(i) for i in ids)})
    error_field = None
    if status == 400:
        match = FIELD_NOT_SUPPORTED.search(body.get("error", ""))
        if match:
            error_field = match.group(1)
    return status, error_field
step3.js
const FIELD_NOT_SUPPORTED = /field '(\w+)' is not supported/i;

async function tryV2IdFilter(ids) {
  const { status, body } = await bcGet("/v2/customers", { id: ids.join(",") });
  let errorField = null;
  if (status === 400) {
    const match = FIELD_NOT_SUPPORTED.exec(body.error || "");
    if (match) errorField = match[1];
  }
  return { status, errorField };
}
4

Decide, with one pure function

Keep the decision in its own function that takes the api version, the query dict, the response status, and the error field, and returns one of three outcomes. v3 is always fine, since id:in is genuinely supported there. A v2 400 on the id field falls back to the direct resource path when only one id was asked for, since that path fetches exactly one customer, or signals a migration to v3 when more than one id was involved.

decide.py
from typing import Literal

def resolve_customer_lookup(
    filter_query: dict, api_version: str, response_status: int, error_field: str | None
) -> Literal["ok_list_filter", "fallback_direct_resource", "migrate_to_v3"]:
    if api_version == "v3":
        return "ok_list_filter"

    if api_version == "v2" and response_status == 400 and error_field == "id":
        requested_ids = filter_query.get("id")
        id_count = len(str(requested_ids).split(",")) if requested_ids else 0
        if id_count <= 1:
            return "fallback_direct_resource"
        return "migrate_to_v3"

    return "ok_list_filter"
decide.js
export function resolveCustomerLookup(filterQuery, apiVersion, responseStatus, errorField) {
  if (apiVersion === "v3") return "ok_list_filter";

  if (apiVersion === "v2" && responseStatus === 400 && errorField === "id") {
    const requestedIds = filterQuery.id;
    const idCount = requestedIds ? String(requestedIds).split(",").length : 0;
    if (idCount <= 1) return "fallback_direct_resource";
    return "migrate_to_v3";
  }

  return "ok_list_filter";
}
5

Reconcile with the direct resource path or the v3 filter

When the decision is fallback_direct_resource, call GET /v2/customers/{id} for that single id. When it is migrate_to_v3, call GET /v3/customers?id:in=1,2,3 once for the whole batch instead of looping the v2 path one id at a time. Either call, on a 200, confirms the customer exists and the original 400 was purely a query-shape issue, not missing data.

reconcile.py
def fetch_customer_direct(customer_id):
    status, body = bc_get(f"/v2/customers/{customer_id}")
    return status, body

def fetch_customers_v3(ids):
    status, body = bc_get("/v3/customers", {"id:in": ",".join(str(i) for i in ids)})
    return status, body
reconcile.js
async function fetchCustomerDirect(customerId) {
  return bcGet(`/v2/customers/${customerId}`);
}

async function fetchCustomersV3(ids) {
  return bcGet("/v3/customers", { "id:in": ids.join(",") });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs which endpoint it would call for each batch of ids, direct resource path or v3 id:in, without changing how your live code actually fetches customers. Read the output, agree with it, then switch it off and let it update the call sites, or use the log as the migration checklist for a manual code change, since this is a code-path fix and not a data mutation.

Run it safe

This script only reads. There is nothing on the BigCommerce side to write or repair, the store data is fine. Guard any bulk migration of call sites behind DRY_RUN=true first, so you only log which endpoints would change before flipping any script over to the direct resource path or the v3 filter.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, attempts the v2 id filter, parses the specific "field not supported" error, decides the correct reconciliation path with a pure function, and confirms the customer's existence through the direct resource path or the v3 batched filter, logging what it would do until you turn the dry run flag off.

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

reconcile_customer_id_filter.py
"""Reconcile BigCommerce customer lookups that were rejected by the v2 id filter.

BigCommerce's v2 Customers resource (GET /v2/customers) only accepts a fixed,
documented set of filter query params, email, name, company, date_created, and
so on. The id field was never implemented as a filterable field on that legacy
list endpoint, unlike the v3 Customers API, which supports the id:in=1,2,3
filter syntax natively. Scripts and SDKs that assume v3-style filter
conventions work uniformly across versions pass ?id=123 to v2 and get a 400,
"The field 'id' is not supported by this resource.", because v2's query-string
filter whitelist simply omits id. The only supported way to fetch a single
customer on v2 is the direct resource path GET /v2/customers/{id}.

This is a client-side query-shape bug, not corrupt store data, so there is
nothing on the BigCommerce side to write or repair. This job attempts the v2
id filter, and on the specific 400 it reconciles a single id through the
direct resource path, or signals a migration to the v3 batched id:in filter
for multiple ids. Safe to run again and again, read-only by default.

Guide: https://www.allanninal.dev/bigcommerce/customer-filter-by-id-unsupported/
"""
import os
import re
import logging
from typing import Literal

import requests

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

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

FIELD_NOT_SUPPORTED = re.compile(r"field '(\w+)' is not supported", re.IGNORECASE)

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


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


def resolve_customer_lookup(
    filter_query: dict, api_version: str, response_status: int, error_field: str | None
) -> Literal["ok_list_filter", "fallback_direct_resource", "migrate_to_v3"]:
    """Pure decision. No network, no side effects.

    if api_version == "v3": always ok_list_filter, id:in is supported there.
    if api_version == "v2" and response_status == 400 and error_field == "id":
        fallback_direct_resource when a single id was requested, since the
        direct resource path only fetches one customer at a time, otherwise
        migrate_to_v3 when multiple ids were requested.
    otherwise: ok_list_filter (the call already succeeded, or failed for an
    unrelated reason that this reconciler does not handle).
    """
    if api_version == "v3":
        return "ok_list_filter"

    if api_version == "v2" and response_status == 400 and error_field == "id":
        requested_ids = filter_query.get("id")
        id_count = len(str(requested_ids).split(",")) if requested_ids else 0
        if id_count <= 1:
            return "fallback_direct_resource"
        return "migrate_to_v3"

    return "ok_list_filter"


def try_v2_id_filter(ids):
    query = {"id": ",".join(str(i) for i in ids)}
    status, body = bc_get("/v2/customers", query)
    error_field = None
    if status == 400:
        match = FIELD_NOT_SUPPORTED.search(body.get("error", ""))
        if match:
            error_field = match.group(1)
    return query, status, error_field


def fetch_customer_direct(customer_id):
    return bc_get(f"/v2/customers/{customer_id}")


def fetch_customers_v3(ids):
    return bc_get("/v3/customers", {"id:in": ",".join(str(i) for i in ids)})


def run(id_batches=None):
    """id_batches: list of lists of customer ids to reconcile, e.g. [[123], [45, 46, 47]]."""
    id_batches = id_batches if id_batches is not None else [[123]]

    reconciled = 0
    migrated = 0

    for ids in id_batches:
        query, status, error_field = try_v2_id_filter(ids)
        decision = resolve_customer_lookup(query, "v2", status, error_field)

        if decision == "ok_list_filter":
            log.info("ids=%s v2 list filter succeeded, no reconciliation needed", ids)
            continue

        if decision == "fallback_direct_resource":
            log.info(
                "ids=%s v2 id filter rejected (%s), %s direct resource path GET /v2/customers/%s",
                ids, error_field, "would call" if DRY_RUN else "calling", ids[0],
            )
            if not DRY_RUN:
                direct_status, direct_body = fetch_customer_direct(ids[0])
                log.info("direct resource path returned status=%s", direct_status)
            reconciled += 1
            continue

        if decision == "migrate_to_v3":
            log.info(
                "ids=%s v2 id filter rejected (%s), %s v3 batched filter GET /v3/customers?id:in=%s",
                ids, error_field, "would call" if DRY_RUN else "calling", ",".join(str(i) for i in ids),
            )
            if not DRY_RUN:
                v3_status, v3_body = fetch_customers_v3(ids)
                log.info("v3 batched filter returned status=%s", v3_status)
            migrated += 1

    log.info(
        "Done. %d batch(es) %s via direct resource path, %d batch(es) %s via v3 id:in.",
        reconciled, "to reconcile" if DRY_RUN else "reconciled",
        migrated, "to migrate" if DRY_RUN else "migrated",
    )


if __name__ == "__main__":
    run()
reconcile-customer-id-filter.js
/**
 * Reconcile BigCommerce customer lookups that were rejected by the v2 id filter.
 *
 * BigCommerce's v2 Customers resource (GET /v2/customers) only accepts a fixed,
 * documented set of filter query params, email, name, company, date_created, and
 * so on. The id field was never implemented as a filterable field on that legacy
 * list endpoint, unlike the v3 Customers API, which supports the id:in=1,2,3
 * filter syntax natively. Scripts and SDKs that assume v3-style filter
 * conventions work uniformly across versions pass ?id=123 to v2 and get a 400,
 * "The field 'id' is not supported by this resource.", because v2's query-string
 * filter whitelist simply omits id. The only supported way to fetch a single
 * customer on v2 is the direct resource path GET /v2/customers/{id}.
 *
 * This is a client-side query-shape bug, not corrupt store data, so there is
 * nothing on the BigCommerce side to write or repair. This job attempts the v2
 * id filter, and on the specific 400 it reconciles a single id through the
 * direct resource path, or signals a migration to the v3 batched id:in filter
 * for multiple ids. Safe to run again and again, read-only by default.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/customer-filter-by-id-unsupported/
 */
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_ROOT = `https://api.bigcommerce.com/stores/${STORE_HASH}`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const FIELD_NOT_SUPPORTED = /field '(\w+)' is not supported/i;

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

/**
 * Pure decision. No network, no side effects.
 *
 * if apiVersion === "v3": always ok_list_filter, id:in is supported there.
 * if apiVersion === "v2" and responseStatus === 400 and errorField === "id":
 *   fallback_direct_resource when a single id was requested, since the
 *   direct resource path only fetches one customer at a time, otherwise
 *   migrate_to_v3 when multiple ids were requested.
 * otherwise: ok_list_filter (the call already succeeded, or failed for an
 * unrelated reason that this reconciler does not handle).
 */
export function resolveCustomerLookup(filterQuery, apiVersion, responseStatus, errorField) {
  if (apiVersion === "v3") return "ok_list_filter";

  if (apiVersion === "v2" && responseStatus === 400 && errorField === "id") {
    const requestedIds = filterQuery.id;
    const idCount = requestedIds ? String(requestedIds).split(",").length : 0;
    if (idCount <= 1) return "fallback_direct_resource";
    return "migrate_to_v3";
  }

  return "ok_list_filter";
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_ROOT}${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 });
  const text = await res.text();
  const body = text ? JSON.parse(text) : {};
  return { status: res.status, body };
}

async function tryV2IdFilter(ids) {
  const query = { id: ids.join(",") };
  const { status, body } = await bcGet("/v2/customers", query);
  let errorField = null;
  if (status === 400) {
    const match = FIELD_NOT_SUPPORTED.exec(body.error || "");
    if (match) errorField = match[1];
  }
  return { query, status, errorField };
}

async function fetchCustomerDirect(customerId) {
  return bcGet(`/v2/customers/${customerId}`);
}

async function fetchCustomersV3(ids) {
  return bcGet("/v3/customers", { "id:in": ids.join(",") });
}

export async function run(idBatches = [[123]]) {
  let reconciled = 0;
  let migrated = 0;

  for (const ids of idBatches) {
    const { query, status, errorField } = await tryV2IdFilter(ids);
    const decision = resolveCustomerLookup(query, "v2", status, errorField);

    if (decision === "ok_list_filter") {
      console.log(`ids=${ids} v2 list filter succeeded, no reconciliation needed`);
      continue;
    }

    if (decision === "fallback_direct_resource") {
      console.log(
        `ids=${ids} v2 id filter rejected (${errorField}), ${DRY_RUN ? "would call" : "calling"} direct resource path GET /v2/customers/${ids[0]}`
      );
      if (!DRY_RUN) {
        const { status: directStatus } = await fetchCustomerDirect(ids[0]);
        console.log(`direct resource path returned status=${directStatus}`);
      }
      reconciled += 1;
      continue;
    }

    if (decision === "migrate_to_v3") {
      console.log(
        `ids=${ids} v2 id filter rejected (${errorField}), ${DRY_RUN ? "would call" : "calling"} v3 batched filter GET /v3/customers?id:in=${ids.join(",")}`
      );
      if (!DRY_RUN) {
        const { status: v3Status } = await fetchCustomersV3(ids);
        console.log(`v3 batched filter returned status=${v3Status}`);
      }
      migrated += 1;
    }
  }

  console.log(
    `Done. ${reconciled} batch(es) ${DRY_RUN ? "to reconcile" : "reconciled"} via direct resource path, ` +
    `${migrated} batch(es) ${DRY_RUN ? "to migrate" : "migrated"} via v3 id:in.`
  );
}

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 the reconciler quietly does nothing, fetches one customer directly, or migrates a whole batch to v3. Because resolve_customer_lookup takes only plain values and returns a plain string, the test needs no network and no BigCommerce store. It just feeds in fixture tuples and checks the answer.

test_customer_id_filter_decision.py
from reconcile_customer_id_filter import resolve_customer_lookup


def test_v3_always_ok_even_with_id_filter():
    assert resolve_customer_lookup({"id:in": "123"}, "v3", 200, None) == "ok_list_filter"


def test_v2_success_is_ok_list_filter():
    assert resolve_customer_lookup({"email": "a@b.com"}, "v2", 200, None) == "ok_list_filter"


def test_v2_single_id_400_falls_back_to_direct_resource():
    assert resolve_customer_lookup({"id": "123"}, "v2", 400, "id") == "fallback_direct_resource"


def test_v2_multiple_ids_400_migrates_to_v3():
    assert resolve_customer_lookup({"id": "123,124,125"}, "v2", 400, "id") == "migrate_to_v3"


def test_v2_400_on_unrelated_field_is_ok_list_filter():
    assert resolve_customer_lookup({"sort": "bogus"}, "v2", 400, "sort") == "ok_list_filter"


def test_v2_400_with_no_error_field_is_ok_list_filter():
    assert resolve_customer_lookup({"id": "123"}, "v2", 400, None) == "ok_list_filter"
reconcile-customer-id-filter.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveCustomerLookup } from "./reconcile-customer-id-filter.js";

test("v3 always ok even with id filter", () => {
  assert.equal(resolveCustomerLookup({ "id:in": "123" }, "v3", 200, null), "ok_list_filter");
});

test("v2 success is ok_list_filter", () => {
  assert.equal(resolveCustomerLookup({ email: "a@b.com" }, "v2", 200, null), "ok_list_filter");
});

test("v2 single id 400 falls back to direct resource", () => {
  assert.equal(resolveCustomerLookup({ id: "123" }, "v2", 400, "id"), "fallback_direct_resource");
});

test("v2 multiple ids 400 migrates to v3", () => {
  assert.equal(resolveCustomerLookup({ id: "123,124,125" }, "v2", 400, "id"), "migrate_to_v3");
});

test("v2 400 on unrelated field is ok_list_filter", () => {
  assert.equal(resolveCustomerLookup({ sort: "bogus" }, "v2", 400, "sort"), "ok_list_filter");
});

test("v2 400 with no error field is ok_list_filter", () => {
  assert.equal(resolveCustomerLookup({ id: "123" }, "v2", 400, null), "ok_list_filter");
});

Case studies

Ported SDK helper

The integration that copied a v3 pattern onto v2 calls

A team building a customer sync job wrote a generic getCustomers(filters) helper against BigCommerce's v3 conventions first, then reused it for an older integration still pinned to v2 for other reasons. The helper passed an id filter straight through. Every lookup for a specific customer failed with a 400, and the team's first instinct was to check whether the customer records had been deleted.

Cross-checking with GET /v2/customers/{id} showed every one of those customers still existed. The fix was in the helper, not the data, route id lookups to the direct resource path on v2, and leave the generic filter helper for fields v2 actually supports.

Bulk reconciliation job

The nightly job that needed dozens of ids at once

A reconciliation job needed to check on a batch of customer ids collected from a separate order export, sometimes forty or fifty at a time. Falling back to GET /v2/customers/{id} one at a time worked, but it meant forty or fifty separate requests every run just to look up ids in bulk.

Since more than one id was involved, the job migrated that lookup to GET /v3/customers?id:in=1,2,3,..., which returned the whole batch in one call with the standard {data, meta.pagination} envelope, cutting the request count from dozens down to one or two paginated calls.

What good looks like

After this reconciler is in place, a v2 400 on the id field never gets mistaken for a missing customer. A single id resolves through the direct resource path, a batch of ids gets routed to the v3 id:in filter, and the store's actual customer data is never touched, because there was never anything wrong with it. The only thing that changes is which endpoint the calling code asks, and how.

FAQ

Why does GET /v2/customers?id=123 return a 400 on BigCommerce?

BigCommerce's v2 Customers resource only accepts a fixed, documented set of filter query params such as email, name, company, and date_created. The id field was never implemented as a filterable field on that legacy list endpoint, so passing ?id=123 returns a 400 with an error body like "The field 'id' is not supported by this resource."

Does this mean the customer record is missing or deleted?

No. A 400 from the v2 filtered list call is a query-shape error, not proof the customer is gone. Confirm by calling GET /v2/customers/{id} directly, or GET /v3/customers?id:in={id}, both of which succeed and return the record if it still exists.

What is the correct fix, patch the query or migrate the endpoint?

Migrate the lookup. For a single id, call the direct resource path GET /v2/customers/{id}. For multiple ids, or for any new code, use GET /v3/customers?id:in=1,2,3, which natively supports batched id filtering and returns the standard data and meta.pagination envelope. There is nothing to repair on the BigCommerce side, since the store data is fine, only the calling code's query shape is wrong.

Related field notes

Citations

On the problem:

  1. bigcommerce-api-php Issue #249: getCustomers filter throws "field 'id' is not supported by this resource". github.com bigcommerce-api-php issue #249
  2. BigCommerce Developer Center: Customers V2 reference and its supported filter params. developer.bigcommerce.com customers v2
  3. BigCommerce Developer Center: common query params and filtering conventions. developer.bigcommerce.com filtering

On the solution:

  1. BigCommerce Developer Center: Customers V3 reference and the id:in filter. developer.bigcommerce.com customers v3
  2. BigCommerce API Reference: Get Customer (v2), the direct resource path. docs.bigcommerce.com get customer (v2)
  3. BigCommerce Developer Center: Customers (v3 reference), request and response shape. developer.bigcommerce.com customers (v3 reference)

Stuck on a tricky one?

If you have a problem in BigCommerce customers, orders, payments, 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 save you a wild goose chase?

If this saved you from thinking a customer record was gone when it was really just a query-shape bug, 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