Diagnostic Customers

Create customer for an existing email errors without returning the existing id

A shopper already has a customer record. Something on your side, a sync job, an import, a signup retry, tries to create them again with POST /v3/customers, and BigCommerce correctly refuses it. But the 422 response only says the email is already in use. It never tells you which customer_id already owns that email, so your only path forward is a second lookup call. Here is why the error is shaped that way and a small script that resolves the real id instead of guessing.

Python and Node.js BigCommerce V3 Customers API Safe by default (dry run)
Two people talking at a table inside a cafe
Photo by Thomas Leblanc on Unsplash
The short answer

BigCommerce enforces email uniqueness for customer records at the database layer. When POST /v3/customers is called with an email that already belongs to a customer, the whole batch is rejected atomically with a 422 whose errors/title contain a message like "The email address ... is already in use by a customer." That payload never includes the conflicting customer's id, and because the create request is an array, the response does not even say which submitted email collided. Catch the 422, take the email(s) you submitted in that batch, and call GET /v3/customers?email:in={email}&include=storecredit,attributes. When data is non-empty, data[0].id is the real customer_id. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce's V3 Customers API accepts a create request as an array of customer objects, even when you are only creating one. The whole array is validated together, and if any email in it already belongs to an existing customer, the entire batch is rejected with a 422 Unprocessable Entity. That is the correct behavior, since BigCommerce will not let two customer records share an email.

The frustrating part is what the error body actually contains. It has a validation message, something to the effect of the email address already being in use by a customer, and a field reference. It does not have a customer_id. It does not even reliably tell you which email in a multi-item batch triggered the rejection. So the natural next question, "okay, which customer already has this email," has no answer inside the response you just got back. You are left holding the email address and nothing else, and you have to go ask the API a second, completely separate question to get the id you actually needed.

POST /v3/customers email already exists Email uniqueness enforced in DB no id in body 422 already in use message only, no id customer_id unknown
The API correctly refuses the duplicate, but the error body has nowhere to put the customer_id, so the caller is left with only the email it already had.

Why it happens

A few things about how the Customers v3 API is built make this the expected shape of the error, not a bug:

The key insight

The 422 body is not a dead end, it is a signal to ask a different, better question. Once you see an "already in use" message, stop treating it as a create failure to retry, and instead resolve the real id with GET /v3/customers?email:in={email}&include=storecredit,attributes. This is not a data-repair scenario, there is no bad state to fix, the customer record is already correct. It is a flag and resolve workflow: catch the error, look up the id, then decide whether to just report it or, if you explicitly want an upsert, update the existing record with PUT /v3/customers instead of ever creating a duplicate.

The fix, as a flow

We do not change how customers get created. We add a small layer around the create call that recognizes the specific "already in use" shape of a 422, decides which submitted email(s) are candidates, and resolves the real customer_id with a targeted GET instead of leaving the caller stuck.

POST /v3/customers catch 422 response Decide (pure fn) already in use? candidates? is duplicate email error? yes no, raise original GET email:in lookup for each candidate email data[0].id resolved customer_id
The pure decision function only classifies the error and lists candidates. The actual GET /v3/customers?email:in= lookup happens outside it, one call per candidate email.

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 (modify) scope so it can create customers and read the customers list. 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 allow the PUT upsert path
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to allow the PUT upsert path
2

Talk to the V3 Customers REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles POST, GET, and PUT, and returns the parsed JSON body even on a non-2xx response, since we need to inspect the 422 payload rather than just throw it away.

step2.py
import os, requests

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

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

def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    return r.status_code, (r.json() if r.text else {})

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

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

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
  const text = await res.text();
  return [res.status, text ? JSON.parse(text) : {}];
}

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

Attempt the create and capture the failed response

Submit the batch to POST /v3/customers exactly as you normally would, an array of customer objects with email required. On a 422, do not throw the response away. Parse the JSON body, it is the input the decision function needs, along with the list of emails you submitted in that same batch.

step3.py
def create_customers(customer_payloads):
    """customer_payloads: list of dicts, each must include an 'email' key."""
    status, body = bc_post("/customers", customer_payloads)
    submitted_emails = [c["email"] for c in customer_payloads if c.get("email")]
    return status, body, submitted_emails
step3.js
async function createCustomers(customerPayloads) {
  // customerPayloads: array of objects, each must include an "email" key.
  const [status, body] = await bcPost("/customers", customerPayloads);
  const submittedEmails = customerPayloads.filter((c) => c.email).map((c) => c.email);
  return { status, body, submittedEmails };
}
4

Decide, with one pure function

Keep the classification in its own function that takes only the parsed 422 body and the list of submitted emails, and returns a plain decision struct. It checks the response's title and errors for an "already in use" style message with a case-insensitive regex, and since the response never says which email collided, it hands back every submitted email as a lookup candidate. No I/O happens inside this function, the actual GET /v3/customers?email:in= call happens outside it.

decide.py
import re

ALREADY_IN_USE_RE = re.compile(r"already in use", re.IGNORECASE)

def resolve_duplicate_customer_action(create_response: dict, submitted_emails: list) -> dict:
    status = create_response.get("status")
    title = create_response.get("title") or ""
    errors = create_response.get("errors") or {}

    messages = [title]
    if isinstance(errors, dict):
        messages.extend(str(v) for v in errors.values())
    elif isinstance(errors, list):
        for e in errors:
            if isinstance(e, dict):
                messages.append(str(e.get("message", "")))
            else:
                messages.append(str(e))

    is_duplicate = status == 422 and any(ALREADY_IN_USE_RE.search(m) for m in messages if m)

    if is_duplicate:
        return {
            "is_duplicate_email_error": True,
            "candidate_emails": list(submitted_emails),
            "next_action": "lookup_by_email",
        }
    return {
        "is_duplicate_email_error": False,
        "candidate_emails": [],
        "next_action": "raise",
    }
decide.js
const ALREADY_IN_USE_RE = /already in use/i;

export function resolveDuplicateCustomerAction(createResponse, submittedEmails) {
  const status = createResponse.status;
  const title = createResponse.title || "";
  const errors = createResponse.errors || {};

  const messages = [title];
  if (Array.isArray(errors)) {
    for (const e of errors) messages.push(typeof e === "object" ? String(e.message || "") : String(e));
  } else if (errors && typeof errors === "object") {
    for (const v of Object.values(errors)) messages.push(String(v));
  }

  const isDuplicate = status === 422 && messages.some((m) => m && ALREADY_IN_USE_RE.test(m));

  if (isDuplicate) {
    return {
      isDuplicateEmailError: true,
      candidateEmails: [...submittedEmails],
      nextAction: "lookup_by_email",
    };
  }
  return {
    isDuplicateEmailError: false,
    candidateEmails: [],
    nextAction: "raise",
  };
}
5

Resolve the real id with an email:in lookup

For each candidate email, call GET /v3/customers?email:in={email}&include=storecredit,attributes. When data is non-empty, data[0].id is the customer_id that already owns that email. Report {email, resolved_customer_id} for every match instead of retrying the create.

lookup.py
def resolve_customer_id_by_email(email):
    body = bc_get("/customers", {"email:in": email, "include": "storecredit,attributes"})
    data = body.get("data") or []
    return data[0]["id"] if data else None
lookup.js
async function resolveCustomerIdByEmail(email) {
  const body = await bcGet("/customers", { "email:in": email, include: "storecredit,attributes" });
  const data = body.data || [];
  return data.length ? data[0].id : null;
}
6

Wire it together, with an optional upsert behind DRY_RUN

The loop attempts the create, and on a 422 runs it through resolve_duplicate_customer_action. If it is not a duplicate-email error, the original error is re-raised, since something else went wrong. If it is, each candidate email is resolved and logged as {email, resolved_customer_id}. Only if DRY_RUN=false and the caller explicitly wants an upsert does it call PUT /v3/customers with [{"id": resolved_customer_id, ...fields}] to update the existing record. With DRY_RUN=true it only reports the resolved id and never writes.

Run it safe

Always start with DRY_RUN=true. This is a flag and resolve workflow, not a data-repair scenario, there is no bad state to fix. Only enable the upsert path if you have explicitly decided you want create-or-update semantics, and always update by the resolved id with PUT /v3/customers, never by submitting another POST /v3/customers with the same email.

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 never creates a second customer record for an email that already has one.

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

resolve_duplicate_customer.py
"""Resolve the real customer_id when POST /v3/customers rejects an existing email.

BigCommerce enforces email uniqueness for customer records at the database layer.
When POST /v3/customers is called with an email that already belongs to a
customer, the API rejects the whole batch atomically with a 422 validation
error ("The email address ... is already in use by a customer."), but the
error payload only has the validation message and field, never the conflicting
customer's id. Because the batch is submitted as an array, the response also
does not say which submitted email collided. This script catches that 422,
classifies it with a pure decision function, and resolves the real id for each
candidate email with GET /v3/customers?email:in={email}. This is not a data
repair scenario, there is no bad state to fix, it is a flag and resolve
workflow. Only if DRY_RUN is false and the caller explicitly wants an upsert
does it PUT the existing record instead of leaving it alone.

Guide: https://www.allanninal.dev/bigcommerce/create-customer-existing-email-no-id-returned/
"""
import os
import re
import logging

import requests

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

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"

ALREADY_IN_USE_RE = re.compile(r"already in use", re.IGNORECASE)

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


def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    return r.status_code, (r.json() if r.text else {})


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


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() if r.text else {}


def resolve_duplicate_customer_action(create_response: dict, submitted_emails: list) -> dict:
    """Pure decision. No network, no side effects.

    Given the parsed JSON body of a failed POST /v3/customers response (with
    .status, .title, .errors) and the list of emails submitted in that batch,
    decide whether this is an "email already in use" collision (regex match on
    title/errors messages) and which submitted email(s) are lookup candidates,
    since the response itself never names them. Returns
    {"is_duplicate_email_error": bool, "candidate_emails": [...],
    "next_action": "lookup_by_email" | "raise"}.
    """
    status = create_response.get("status")
    title = create_response.get("title") or ""
    errors = create_response.get("errors") or {}

    messages = [title]
    if isinstance(errors, dict):
        messages.extend(str(v) for v in errors.values())
    elif isinstance(errors, list):
        for e in errors:
            if isinstance(e, dict):
                messages.append(str(e.get("message", "")))
            else:
                messages.append(str(e))

    is_duplicate = status == 422 and any(
        ALREADY_IN_USE_RE.search(m) for m in messages if m
    )

    if is_duplicate:
        return {
            "is_duplicate_email_error": True,
            "candidate_emails": list(submitted_emails),
            "next_action": "lookup_by_email",
        }
    return {
        "is_duplicate_email_error": False,
        "candidate_emails": [],
        "next_action": "raise",
    }


def create_customers(customer_payloads):
    status, body = bc_post("/customers", customer_payloads)
    submitted_emails = [c["email"] for c in customer_payloads if c.get("email")]
    return status, body, submitted_emails


def resolve_customer_id_by_email(email):
    body = bc_get("/customers", {"email:in": email, "include": "storecredit,attributes"})
    data = body.get("data") or []
    return data[0]["id"] if data else None


def upsert_customer(customer_id, fields):
    payload = dict(fields)
    payload["id"] = customer_id
    return bc_put("/customers", [payload])


def run(customer_payloads, upsert_fields=None):
    status, body, submitted_emails = create_customers(customer_payloads)

    if status in (200, 201):
        log.info("Created %d customer(s).", len(body.get("data", [])))
        return

    create_response = {"status": status, "title": body.get("title"), "errors": body.get("errors")}
    decision = resolve_duplicate_customer_action(create_response, submitted_emails)

    if not decision["is_duplicate_email_error"]:
        raise RuntimeError(f"BigCommerce create failed: status={status} body={body}")

    for email in decision["candidate_emails"]:
        resolved_id = resolve_customer_id_by_email(email)
        if resolved_id is None:
            log.warning("email=%s flagged as duplicate but no matching customer found.", email)
            continue

        log.info("email=%s resolved_customer_id=%s", email, resolved_id)

        if not DRY_RUN and upsert_fields is not None:
            upsert_customer(resolved_id, upsert_fields)
            log.info("email=%s customer_id=%s updated via PUT /v3/customers", email, resolved_id)


if __name__ == "__main__":
    run([{"email": "shopper@example.com", "first_name": "Jamie", "last_name": "Rivera"}])
resolve-duplicate-customer.js
/**
 * Resolve the real customer_id when POST /v3/customers rejects an existing email.
 *
 * BigCommerce enforces email uniqueness for customer records at the database
 * layer. When POST /v3/customers is called with an email that already belongs
 * to a customer, the API rejects the whole batch atomically with a 422
 * validation error ("The email address ... is already in use by a
 * customer."), but the error payload only has the validation message and
 * field, never the conflicting customer's id. Because the batch is submitted
 * as an array, the response also does not say which submitted email
 * collided. This script catches that 422, classifies it with a pure decision
 * function, and resolves the real id for each candidate email with
 * GET /v3/customers?email:in={email}. This is not a data repair scenario,
 * there is no bad state to fix, it is a flag and resolve workflow. Only if
 * DRY_RUN is false and the caller explicitly wants an upsert does it PUT the
 * existing record instead of leaving it alone.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/create-customer-existing-email-no-id-returned/
 */
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 ALREADY_IN_USE_RE = /already in use/i;

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

/**
 * Pure decision. No network, no side effects.
 *
 * Given the parsed JSON body of a failed POST /v3/customers response (with
 * .status, .title, .errors) and the list of emails submitted in that batch,
 * decide whether this is an "email already in use" collision (regex match on
 * title/errors messages) and which submitted email(s) are lookup candidates,
 * since the response itself never names them. Returns
 * { isDuplicateEmailError, candidateEmails, nextAction }.
 */
export function resolveDuplicateCustomerAction(createResponse, submittedEmails) {
  const status = createResponse.status;
  const title = createResponse.title || "";
  const errors = createResponse.errors || {};

  const messages = [title];
  if (Array.isArray(errors)) {
    for (const e of errors) {
      messages.push(typeof e === "object" && e !== null ? String(e.message || "") : String(e));
    }
  } else if (errors && typeof errors === "object") {
    for (const v of Object.values(errors)) messages.push(String(v));
  }

  const isDuplicate = status === 422 && messages.some((m) => m && ALREADY_IN_USE_RE.test(m));

  if (isDuplicate) {
    return {
      isDuplicateEmailError: true,
      candidateEmails: [...submittedEmails],
      nextAction: "lookup_by_email",
    };
  }
  return {
    isDuplicateEmailError: false,
    candidateEmails: [],
    nextAction: "raise",
  };
}

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  const text = await res.text();
  return [res.status, text ? JSON.parse(text) : {}];
}

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

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}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function createCustomers(customerPayloads) {
  const [status, body] = await bcPost("/customers", customerPayloads);
  const submittedEmails = customerPayloads.filter((c) => c.email).map((c) => c.email);
  return { status, body, submittedEmails };
}

async function resolveCustomerIdByEmail(email) {
  const body = await bcGet("/customers", { "email:in": email, include: "storecredit,attributes" });
  const data = body.data || [];
  return data.length ? data[0].id : null;
}

async function upsertCustomer(customerId, fields) {
  const payload = { ...fields, id: customerId };
  return bcPut("/customers", [payload]);
}

export async function run(customerPayloads, upsertFields = null) {
  const { status, body, submittedEmails } = await createCustomers(customerPayloads);

  if (status === 200 || status === 201) {
    console.log(`Created ${(body.data || []).length} customer(s).`);
    return;
  }

  const createResponse = { status, title: body.title, errors: body.errors };
  const decision = resolveDuplicateCustomerAction(createResponse, submittedEmails);

  if (!decision.isDuplicateEmailError) {
    throw new Error(`BigCommerce create failed: status=${status} body=${JSON.stringify(body)}`);
  }

  for (const email of decision.candidateEmails) {
    const resolvedId = await resolveCustomerIdByEmail(email);
    if (resolvedId === null) {
      console.warn(`email=${email} flagged as duplicate but no matching customer found.`);
      continue;
    }

    console.log(`email=${email} resolved_customer_id=${resolvedId}`);

    if (!DRY_RUN && upsertFields !== null) {
      await upsertCustomer(resolvedId, upsertFields);
      console.log(`email=${email} customer_id=${resolvedId} updated via PUT /v3/customers`);
    }
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run([{ email: "shopper@example.com", first_name: "Jamie", last_name: "Rivera" }]).catch((err) => {
    console.error(err);
    process.exit(1);
  });
}

Add a test

The decision function is the part most worth testing, because it decides whether the script quietly resolves an id or throws a real error up the chain. Because resolve_duplicate_customer_action takes only plain values and returns a plain struct, the test needs no network and no BigCommerce store. It just feeds in a parsed error body and checks the answer.

test_customer_duplicate_email.py
from resolve_duplicate_customer import resolve_duplicate_customer_action


def already_in_use_response(field="email"):
    return {
        "status": 422,
        "title": "The email address you entered is already in use by a customer.",
        "errors": {field: "already in use"},
    }


def test_flags_already_in_use_error_as_duplicate():
    decision = resolve_duplicate_customer_action(already_in_use_response(), ["shopper@example.com"])
    assert decision["is_duplicate_email_error"] is True
    assert decision["next_action"] == "lookup_by_email"


def test_returns_all_submitted_emails_as_candidates():
    decision = resolve_duplicate_customer_action(
        already_in_use_response(), ["a@example.com", "b@example.com"]
    )
    assert decision["candidate_emails"] == ["a@example.com", "b@example.com"]


def test_ignores_unrelated_422_errors():
    response = {"status": 422, "title": "First name is required.", "errors": {"first_name": "required"}}
    decision = resolve_duplicate_customer_action(response, ["shopper@example.com"])
    assert decision["is_duplicate_email_error"] is False
    assert decision["next_action"] == "raise"
    assert decision["candidate_emails"] == []


def test_ignores_non_422_status_even_with_matching_message():
    response = {"status": 500, "title": "already in use", "errors": {}}
    decision = resolve_duplicate_customer_action(response, ["shopper@example.com"])
    assert decision["is_duplicate_email_error"] is False


def test_matches_message_inside_errors_list_form():
    response = {
        "status": 422,
        "title": "Unprocessable Entity",
        "errors": [{"message": "Email address already in use by a customer."}],
    }
    decision = resolve_duplicate_customer_action(response, ["shopper@example.com"])
    assert decision["is_duplicate_email_error"] is True
resolve-duplicate-customer.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveDuplicateCustomerAction } from "./resolve-duplicate-customer.js";

const alreadyInUseResponse = (field = "email") => ({
  status: 422,
  title: "The email address you entered is already in use by a customer.",
  errors: { [field]: "already in use" },
});

test("flags already in use error as duplicate", () => {
  const decision = resolveDuplicateCustomerAction(alreadyInUseResponse(), ["shopper@example.com"]);
  assert.equal(decision.isDuplicateEmailError, true);
  assert.equal(decision.nextAction, "lookup_by_email");
});

test("returns all submitted emails as candidates", () => {
  const decision = resolveDuplicateCustomerAction(alreadyInUseResponse(), ["a@example.com", "b@example.com"]);
  assert.deepEqual(decision.candidateEmails, ["a@example.com", "b@example.com"]);
});

test("ignores unrelated 422 errors", () => {
  const response = { status: 422, title: "First name is required.", errors: { first_name: "required" } };
  const decision = resolveDuplicateCustomerAction(response, ["shopper@example.com"]);
  assert.equal(decision.isDuplicateEmailError, false);
  assert.equal(decision.nextAction, "raise");
  assert.deepEqual(decision.candidateEmails, []);
});

test("ignores non 422 status even with matching message", () => {
  const response = { status: 500, title: "already in use", errors: {} };
  const decision = resolveDuplicateCustomerAction(response, ["shopper@example.com"]);
  assert.equal(decision.isDuplicateEmailError, false);
});

test("matches message inside errors list form", () => {
  const response = {
    status: 422,
    title: "Unprocessable Entity",
    errors: [{ message: "Email address already in use by a customer." }],
  };
  const decision = resolveDuplicateCustomerAction(response, ["shopper@example.com"]);
  assert.equal(decision.isDuplicateEmailError, true);
});

Case studies

Nightly customer import

The import job that stalled on its first repeat run

A store synced customers nightly from an external CRM. The first run created every customer cleanly. The second run, re-submitting the same export because the CRM had no incremental flag, hit a wall of 422s, one per shopper who already existed, and the job's error handling just logged the raw response and stopped, with no customer_id to act on.

Once the job caught the "already in use" shape and resolved each email with email:in, the second run stopped failing. Existing shoppers were recognized, their ids logged, and only genuinely new emails were created.

Signup retry from the storefront

The checkout flow that double-submitted a guest signup

A flaky connection made a storefront app retry a "create account at checkout" request. The first attempt actually succeeded, but the client never saw the response, so it retried with the same email. The retry's 422 gave the app nothing to work with, and it surfaced a generic error to the shopper who, from their side, already had an account.

Adding the lookup step let the app recognize its own retry, resolve the existing customer_id, and quietly continue checkout as that customer instead of showing an error for something that had, in fact, already worked.

What good looks like

After this is in place, a 422 already in use response is never a dead end. It gets classified in one place, resolved to a real customer_id with a single targeted lookup, and logged or upserted deliberately, never silently retried as a duplicate create. Anything that is not actually an email collision still raises, so real validation problems are never swallowed by this path.

FAQ

Why does BigCommerce reject a customer create with an existing email but not tell me the customer_id?

BigCommerce enforces email uniqueness for customer records at the database layer. When POST /v3/customers is called with an email that already belongs to a customer, the API rejects the whole batch with a 422 validation error whose message says the email is already in use, but the error payload only contains the validation message and field, not the conflicting customer's id, so the client has no way to recover the id from the failed response itself.

How do I find the customer_id for an email that a create call rejected?

Call GET /v3/customers with the query filter email:in={email}, optionally with include=storecredit,attributes. If a customer with that email exists, data[0].id is the customer_id. This is a separate lookup call, because the 422 response from POST /v3/customers never enumerates which submitted email or emails collided.

Is a 422 already in use error a data problem I need to repair?

No. There is no bad state to correct, the customer record already exists correctly. This is a flag and resolve workflow: catch the 422, resolve the real id with an email:in lookup, and either report the id or, only if you explicitly want an upsert, use PUT /v3/customers with that id to update the existing record instead of creating a duplicate.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: customer already exists, does not return customer id. support.bigcommerce.com customer already exists, does not return customer id
  2. BigCommerce Support: check existing customer by email. support.bigcommerce.com check existing customer by email
  3. BigCommerce Support: 422 create customers failed. support.bigcommerce.com 422 create customers failed

On the solution:

  1. BigCommerce API Reference: Create Customers (POST /v3/customers). developer.bigcommerce.com customers (v3)
  2. BigCommerce Developer Center: Customers v3 API reference, filtering by email:in. developer.bigcommerce.com customers v3 API reference
  3. BigCommerce Developer Center: Filtering. developer.bigcommerce.com filtering

Stuck on a tricky one?

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

Contact me on LinkedIn

Did this save you a lost customer_id?

If this saved you from a stuck import job or a confusing signup retry, 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