Diagnostic Customers
Customer password update intermittently returns random 400 errors
You call the same endpoint with the same shape of request, and sometimes it works and sometimes it returns a 400 with no pattern you can find. The BigCommerce customers endpoint is a batch array API, it enforces password rules it never shows you, and it caps concurrent requests at 3. A status code alone cannot tell you whether the password actually failed or the write actually landed. Here is why the 400 looks random and a small script that checks the truth before it decides anything.
PUT /v3/customers takes a JSON array of customer objects, up to 10 per call, and BigCommerce allows only 3 concurrent requests to this endpoint. It also validates each element's authentication.new_password against the store's password complexity and history rules server-side, without exposing those rules through the same endpoint. A 400 can mean the password genuinely failed that hidden check, or it can mean you crossed the concurrency ceiling, or it can be a stale error on a retry after the password was already written. Do not trust the status code alone. Check date_modified before and after the call, or confirm with POST /v3/customers/validate-credentials, before you decide whether to retry, mark it resolved, or flag it for a human. Full code, tests, and a dry run guard are below.
The problem in plain words
The customers endpoint in BigCommerce's V3 API is built around batch semantics. Even a script updating exactly one customer's password has to send [{"id": 118, "authentication": {"new_password": "...", "force_password_reset": false}}], an array with one element, because the endpoint has no single-object form. That single design choice is where most of the confusion starts, because a batch endpoint reports outcomes per item inside the response body, not only through the top-level HTTP status.
On top of that, BigCommerce checks the new password against the store's own complexity and history rules, minimum length, character variety, whether it was recently reused, entirely server side. Those rules are not published back through this endpoint. If a generated or reused password fails a rule you cannot see, the element for that customer gets a 400, even though your request was shaped correctly and every other field validated fine. And because this endpoint allows only 3 concurrent requests, a script firing more than that can collide with its own limit and get a 400 that has nothing to do with the password at all.
The result looks nondeterministic from the outside. Same code, same password generator, same customer, and one run reports a 400 while a retry moments later reports success, or a top-level 200 comes back while one array element quietly failed underneath it.
Why it happens
A few concrete ways this shows up in a real integration:
- A generated password fails the store's minimum length, character variety, or reuse history rule, none of which are exposed through
PUT /v3/customers, so the 400 gives no actionable detail about which rule tripped. - A script or job runner fires more than 3 concurrent requests at this endpoint, BigCommerce rejects the excess with a 400 rather than only a 429, and the failure looks identical to a validation error unless you check
X-Rate-Limit-Requests-Left. - A retry fires after a prior call actually succeeded, either because the response was slow to arrive or a batch element was misread, and the retry's 400 (password already changed, so it fails a reuse check, or a stale request conflict) gets logged as if the original update failed.
- The top-level HTTP status for the batch reads 200 or 207 while one specific array element inside
data/errorsactually failed, so a script that only checks the outer status code misses the per-item failure entirely.
None of this is a bug in BigCommerce's API. It is a batch, array-based endpoint with server-side validation rules it deliberately does not echo back, paired with a concurrency limit that produces the same status code family as a validation failure. See the citations at the end for the exact API references.
The HTTP status code from PUT /v3/customers is not proof of anything by itself. The response body's per-item data versus errors entry is closer to the truth, and the customer record's own date_modified timestamp, or a direct call to POST /v3/customers/validate-credentials, is the actual ground truth. So the safe pattern is never "a 400 means retry with the same password." It is "check whether the write actually landed before deciding anything," and only resend when the failure is confirmed and belongs to a transient class like rate limiting or a concurrency collision.
The fix, as a flow
We do not touch the live checkout, login, or password reset flow. We add a small check that runs right after any password-update call reports a non-2xx, or right after we want to confirm a suspicious 200, and it decides per customer whether the write is confirmed, worth a bounded retry, or a case for a human.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Customers (modify) scope so it can update authentication.new_password and read customer records. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MAX_RETRIES="3"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export MAX_RETRIES="3"
export DRY_RUN="true" // start safe, change to false to write
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 GET and PUT, returns the parsed body even on a non-2xx, and never raises on the status code alone, because we need to inspect the body ourselves.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
body = r.json() if r.text else {}
return r.status_code, body
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
payload = r.json() if r.text else {}
return r.status_code, payload, r.headers
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
const text = await res.text();
return [res.status, text ? JSON.parse(text) : {}];
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
const text = await res.text();
const payload = text ? JSON.parse(text) : {};
return [res.status, payload, res.headers];
}
Capture the before state, then send the update
Before calling PUT /v3/customers, call GET /v3/customers?id:in={customer_id}&include=... and record data[0].date_modified. Then send the password update wrapped in an array, even for one customer, exactly as [{"id": customer_id, "authentication": {"new_password": password, "force_password_reset": false}}].
def get_customer_date_modified(customer_id):
status, body = bc_get("/customers", {"id:in": customer_id})
data = body.get("data") or []
return data[0].get("date_modified") if data else None
def update_password(customer_id, new_password):
body = [{
"id": customer_id,
"authentication": {"new_password": new_password, "force_password_reset": False},
}]
return bc_put("/customers", body)
async function getCustomerDateModified(customerId) {
const [, body] = await bcGet("/customers", { "id:in": customerId });
const data = body.data || [];
return data.length ? data[0].date_modified : null;
}
async function updatePassword(customerId, newPassword) {
const body = [{
id: customerId,
authentication: { new_password: newPassword, force_password_reset: false },
}];
return bcPut("/customers", body);
}
Decide, with one pure function
Keep the decision in its own function that takes the pre and post date_modified values, the HTTP status, the response body, and the customer id, and returns one of three outcomes. If date_modified actually advanced, the write happened, full stop, no matter what the status code says. Otherwise a transient status class gets a bounded retry, and anything else, most often a persistent complexity or history validation error, goes to a human.
from typing import Literal
MAX_RETRIES = 3
TRANSIENT_STATUSES = {429}
def decide_password_update_outcome(
pre_date_modified: str,
post_date_modified: str,
http_status: int,
response_body: dict,
customer_id: int,
retry_count: int = 0,
) -> Literal["confirmed_success", "needs_retry", "needs_human_review"]:
if post_date_modified and post_date_modified != pre_date_modified:
return "confirmed_success"
is_server_error = 500 <= http_status < 600
is_rate_or_concurrency = http_status in TRANSIENT_STATUSES or _looks_like_concurrency_error(
response_body, customer_id
)
if (is_server_error or is_rate_or_concurrency) and retry_count < MAX_RETRIES:
return "needs_retry"
return "needs_human_review"
def _looks_like_concurrency_error(response_body: dict, customer_id: int) -> bool:
title = (response_body.get("title") or "").lower()
if "concurrent" in title or "rate" in title or "too many" in title:
return True
for error in response_body.get("errors") or []:
text = str(error).lower()
if "concurrent" in text or "rate" in text:
return True
return False
const MAX_RETRIES = 3;
const TRANSIENT_STATUSES = new Set([429]);
export function decidePasswordUpdateOutcome(
preDateModified,
postDateModified,
httpStatus,
responseBody,
customerId,
retryCount = 0
) {
if (postDateModified && postDateModified !== preDateModified) {
return "confirmed_success";
}
const isServerError = httpStatus >= 500 && httpStatus < 600;
const isRateOrConcurrency =
TRANSIENT_STATUSES.has(httpStatus) || looksLikeConcurrencyError(responseBody);
if ((isServerError || isRateOrConcurrency) && retryCount < MAX_RETRIES) {
return "needs_retry";
}
return "needs_human_review";
}
function looksLikeConcurrencyError(responseBody) {
const title = String(responseBody?.title || "").toLowerCase();
if (title.includes("concurrent") || title.includes("rate") || title.includes("too many")) {
return true;
}
for (const error of responseBody?.errors || []) {
const text = String(error).toLowerCase();
if (text.includes("concurrent") || text.includes("rate")) return true;
}
return false;
}
Confirm with validate-credentials when date_modified is not enough
Some stores update date_modified on other fields too, so treat it as the fast check, not the only one. When you need ground truth, or the diff is ambiguous, call POST /v3/customers/validate-credentials with the customer's email and the new password. A pass means the password is active on the account regardless of what the original PUT reported.
def validate_credentials(email, password):
status, body = bc_get_post_validate("/customers/validate-credentials", {
"email": email,
"password": password,
})
return status == 200
async function validateCredentials(email, password) {
const res = await fetch(`${API_BASE}/customers/validate-credentials`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ email, password }),
});
return res.status === 200;
}
Wire it together with a dry run guard and a retry ceiling
The loop ties every piece together. It never resends a raw password on the first non-2xx. Instead it re-runs detection, the date_modified diff first, then validate-credentials if needed, and only queues a corrective retry for a confirmed needs_retry outcome, capped at 3 concurrent requests, batching at most 10 customers per call, and honoring X-Retry-After for backoff. Anything that comes back needs_human_review is logged and left alone.
Always start with DRY_RUN=true, and never auto-resubmit a raw password on a bare 400. Confirm the write really failed first, because resending a password that already landed can retrigger a forced password reset or a reset email the customer never asked for.
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 sends more than 3 concurrent requests or more than 10 customers per batch to this endpoint.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Tell a genuine BigCommerce password-update failure apart from a false 400.
PUT /v3/customers is a batch array endpoint, capped at 3 concurrent requests,
that validates authentication.new_password against the store's password
complexity and history rules server side without exposing those rules through
the same response. A 400 for one array element can mean the password genuinely
failed a hidden rule, or it can mean the request collided with the concurrency
ceiling, or it can be a stale error on a retry after the password was already
written. The HTTP status code alone cannot tell these apart, because the
response body carries the authoritative per item outcome, and the customer's
own date_modified timestamp is closer to ground truth than any status code.
This script never auto-resubmits a raw password on a bare 400. It re-checks
every customer whose PUT returned non-2xx by diffing date_modified, and by
calling validate-credentials when that diff is not conclusive, and only queues
a corrective retry for a confirmed still-failed write in a transient status
class. A persistent complexity or history failure is reported to a human, not
retried. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/customer-password-update-random-400/
"""
import os
import time
import logging
from typing import Literal
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("recheck_password_updates")
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"
MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "3"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
MAX_CONCURRENT_REQUESTS = 3
MAX_BATCH_SIZE = 10
TRANSIENT_STATUSES = {429}
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
body = r.json() if r.text else {}
return r.status_code, body, r.headers
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
payload = r.json() if r.text else {}
return r.status_code, payload, r.headers
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
payload = r.json() if r.text else {}
return r.status_code, payload, r.headers
def _looks_like_concurrency_error(response_body: dict) -> bool:
title = (response_body.get("title") or "").lower()
if "concurrent" in title or "rate" in title or "too many" in title:
return True
for error in response_body.get("errors") or []:
text = str(error).lower()
if "concurrent" in text or "rate" in text:
return True
return False
def decide_password_update_outcome(
pre_date_modified: str,
post_date_modified: str,
http_status: int,
response_body: dict,
customer_id: int,
retry_count: int = 0,
) -> Literal["confirmed_success", "needs_retry", "needs_human_review"]:
"""Pure decision. No network, no side effects.
If post_date_modified advanced past pre_date_modified, the write happened,
regardless of http_status or the response body. Otherwise a transient
status class (429, a 500-range error, or a per-item error object naming a
rate or concurrency problem) gets a bounded retry. Everything else,
typically a persistent complexity or history validation failure, needs a
human, never an automatic resend of the raw password.
"""
if post_date_modified and post_date_modified != pre_date_modified:
return "confirmed_success"
is_server_error = 500 <= http_status < 600
is_rate_or_concurrency = http_status in TRANSIENT_STATUSES or _looks_like_concurrency_error(
response_body
)
if (is_server_error or is_rate_or_concurrency) and retry_count < MAX_RETRIES:
return "needs_retry"
return "needs_human_review"
def get_customer_date_modified(customer_id):
status, body, _ = bc_get("/customers", {"id:in": customer_id})
data = body.get("data") or []
return data[0].get("date_modified") if data else None
def update_password(customer_id, new_password):
body = [{
"id": customer_id,
"authentication": {"new_password": new_password, "force_password_reset": False},
}]
return bc_put("/customers", body)
def validate_credentials(email, password):
status, _, _ = bc_post("/customers/validate-credentials", {
"email": email,
"password": password,
})
return status == 200
def recheck_and_repair(pending_updates):
"""pending_updates: list of dicts with id, email, new_password, pre_date_modified,
http_status, response_body captured from the original PUT call.
Confirms each one via date_modified, falling back to validate-credentials,
then only queues a bounded retry for confirmed transient failures. Returns
a summary dict for logging.
"""
confirmed = 0
retried = 0
flagged = 0
in_flight = 0
for record in pending_updates:
if in_flight >= MAX_CONCURRENT_REQUESTS:
time.sleep(0.2)
in_flight = 0
customer_id = record["id"]
post_date_modified = get_customer_date_modified(customer_id)
in_flight += 1
outcome = decide_password_update_outcome(
record["pre_date_modified"],
post_date_modified,
record["http_status"],
record["response_body"],
customer_id,
record.get("retry_count", 0),
)
if outcome == "confirmed_success":
log.info("customer_id=%s confirmed_success (date_modified advanced)", customer_id)
confirmed += 1
continue
if outcome == "needs_retry":
if validate_credentials(record["email"], record["new_password"]):
log.info(
"customer_id=%s confirmed_success via validate-credentials, no resend",
customer_id,
)
confirmed += 1
continue
log.warning(
"customer_id=%s needs_retry (status=%s), %s",
customer_id, record["http_status"],
"dry run, not resending" if DRY_RUN else "resending",
)
if not DRY_RUN:
for batch_start in range(0, 1, MAX_BATCH_SIZE):
update_password(customer_id, record["new_password"])
retried += 1
continue
log.error(
"customer_id=%s needs_human_review (status=%s) email=%s",
customer_id, record["http_status"], record.get("email"),
)
flagged += 1
log.info(
"Done. %d confirmed, %d retried, %d flagged for human review.",
confirmed, retried, flagged,
)
return {"confirmed": confirmed, "retried": retried, "flagged": flagged}
def run():
# In production this list comes from your job's own record of which PUT
# calls returned non-2xx, captured at call time alongside pre_date_modified.
pending_updates = []
recheck_and_repair(pending_updates)
if __name__ == "__main__":
run()
/**
* Tell a genuine BigCommerce password-update failure apart from a false 400.
*
* PUT /v3/customers is a batch array endpoint, capped at 3 concurrent requests,
* that validates authentication.new_password against the store's password
* complexity and history rules server side without exposing those rules
* through the same response. A 400 for one array element can mean the
* password genuinely failed a hidden rule, or it can mean the request
* collided with the concurrency ceiling, or it can be a stale error on a
* retry after the password was already written. The HTTP status code alone
* cannot tell these apart, because the response body carries the
* authoritative per item outcome, and the customer's own date_modified
* timestamp is closer to ground truth than any status code.
*
* This script never auto-resubmits a raw password on a bare 400. It
* re-checks every customer whose PUT returned non-2xx by diffing
* date_modified, and by calling validate-credentials when that diff is not
* conclusive, and only queues a corrective retry for a confirmed still-failed
* write in a transient status class. A persistent complexity or history
* failure is reported to a human, not retried.
*
* Guide: https://www.allanninal.dev/bigcommerce/customer-password-update-random-400/
*/
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 MAX_RETRIES = Number(process.env.MAX_RETRIES || 3);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const MAX_CONCURRENT_REQUESTS = 3;
const MAX_BATCH_SIZE = 10;
const TRANSIENT_STATUSES = new Set([429]);
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
function looksLikeConcurrencyError(responseBody) {
const title = String(responseBody?.title || "").toLowerCase();
if (title.includes("concurrent") || title.includes("rate") || title.includes("too many")) {
return true;
}
for (const error of responseBody?.errors || []) {
const text = String(error).toLowerCase();
if (text.includes("concurrent") || text.includes("rate")) return true;
}
return false;
}
/**
* Pure decision. No network, no side effects.
*
* If postDateModified advanced past preDateModified, the write happened,
* regardless of httpStatus or the response body. Otherwise a transient
* status class (429, a 500-range error, or a per-item error object naming a
* rate or concurrency problem) gets a bounded retry. Everything else,
* typically a persistent complexity or history validation failure, needs a
* human, never an automatic resend of the raw password.
*/
export function decidePasswordUpdateOutcome(
preDateModified,
postDateModified,
httpStatus,
responseBody,
customerId,
retryCount = 0
) {
if (postDateModified && postDateModified !== preDateModified) {
return "confirmed_success";
}
const isServerError = httpStatus >= 500 && httpStatus < 600;
const isRateOrConcurrency =
TRANSIENT_STATUSES.has(httpStatus) || looksLikeConcurrencyError(responseBody);
if ((isServerError || isRateOrConcurrency) && retryCount < MAX_RETRIES) {
return "needs_retry";
}
return "needs_human_review";
}
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 });
const text = await res.text();
return [res.status, text ? JSON.parse(text) : {}, res.headers];
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
const text = await res.text();
return [res.status, text ? JSON.parse(text) : {}, res.headers];
}
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) : {}, res.headers];
}
async function getCustomerDateModified(customerId) {
const [, body] = await bcGet("/customers", { "id:in": customerId });
const data = body.data || [];
return data.length ? data[0].date_modified : null;
}
async function updatePassword(customerId, newPassword) {
const body = [{
id: customerId,
authentication: { new_password: newPassword, force_password_reset: false },
}];
return bcPut("/customers", body);
}
async function validateCredentials(email, password) {
const [status] = await bcPost("/customers/validate-credentials", { email, password });
return status === 200;
}
/**
* pendingUpdates: array of { id, email, newPassword, preDateModified,
* httpStatus, responseBody, retryCount } captured from the original PUT call.
*
* Confirms each one via date_modified, falling back to validate-credentials,
* then only queues a bounded retry for confirmed transient failures. Returns
* a summary object for logging.
*/
export async function recheckAndRepair(pendingUpdates) {
let confirmed = 0;
let retried = 0;
let flagged = 0;
let inFlight = 0;
for (const record of pendingUpdates) {
if (inFlight >= MAX_CONCURRENT_REQUESTS) {
await new Promise((resolve) => setTimeout(resolve, 200));
inFlight = 0;
}
const customerId = record.id;
const postDateModified = await getCustomerDateModified(customerId);
inFlight += 1;
const outcome = decidePasswordUpdateOutcome(
record.preDateModified,
postDateModified,
record.httpStatus,
record.responseBody,
customerId,
record.retryCount || 0
);
if (outcome === "confirmed_success") {
console.log(`customer_id=${customerId} confirmed_success (date_modified advanced)`);
confirmed += 1;
continue;
}
if (outcome === "needs_retry") {
if (await validateCredentials(record.email, record.newPassword)) {
console.log(`customer_id=${customerId} confirmed_success via validate-credentials, no resend`);
confirmed += 1;
continue;
}
console.warn(
`customer_id=${customerId} needs_retry (status=${record.httpStatus}), ` +
`${DRY_RUN ? "dry run, not resending" : "resending"}`
);
if (!DRY_RUN) {
for (let batchStart = 0; batchStart < 1; batchStart += MAX_BATCH_SIZE) {
await updatePassword(customerId, record.newPassword);
}
}
retried += 1;
continue;
}
console.error(`customer_id=${customerId} needs_human_review (status=${record.httpStatus}) email=${record.email}`);
flagged += 1;
}
console.log(`Done. ${confirmed} confirmed, ${retried} retried, ${flagged} flagged for human review.`);
return { confirmed, retried, flagged };
}
export async function run() {
// In production this list comes from your job's own record of which PUT
// calls returned non-2xx, captured at call time alongside preDateModified.
const pendingUpdates = [];
await recheckAndRepair(pendingUpdates);
}
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 customer's account gets a resent password, a silent pass, or a human review flag. Because decide_password_update_outcome takes only plain values and returns a plain string, the test needs no network and no BigCommerce store. It just feeds in fixed timestamps, statuses, and bodies, and checks the answer.
from recheck_password_updates import decide_password_update_outcome
def test_confirmed_success_when_date_modified_advances_despite_400():
result = decide_password_update_outcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:05Z", 400, {"errors": ["stale retry"]}, 118
)
assert result == "confirmed_success"
def test_needs_retry_on_rate_limit_status():
result = decide_password_update_outcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 429, {}, 118, retry_count=0
)
assert result == "needs_retry"
def test_needs_retry_on_server_error():
result = decide_password_update_outcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 500, {}, 118, retry_count=1
)
assert result == "needs_retry"
def test_needs_retry_on_concurrency_error_body():
body = {"title": "Too many concurrent requests"}
result = decide_password_update_outcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 400, body, 118, retry_count=0
)
assert result == "needs_retry"
def test_needs_human_review_when_retries_exhausted():
result = decide_password_update_outcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 429, {}, 118, retry_count=3
)
assert result == "needs_human_review"
def test_needs_human_review_on_persistent_complexity_error():
body = {"title": "The password does not meet complexity requirements."}
result = decide_password_update_outcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 400, body, 118, retry_count=0
)
assert result == "needs_human_review"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decidePasswordUpdateOutcome } from "./recheck-password-updates.js";
test("confirmed_success when date_modified advances despite a 400", () => {
const result = decidePasswordUpdateOutcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:05Z", 400, { errors: ["stale retry"] }, 118
);
assert.equal(result, "confirmed_success");
});
test("needs_retry on rate limit status", () => {
const result = decidePasswordUpdateOutcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 429, {}, 118, 0
);
assert.equal(result, "needs_retry");
});
test("needs_retry on server error", () => {
const result = decidePasswordUpdateOutcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 500, {}, 118, 1
);
assert.equal(result, "needs_retry");
});
test("needs_retry on concurrency error body", () => {
const body = { title: "Too many concurrent requests" };
const result = decidePasswordUpdateOutcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 400, body, 118, 0
);
assert.equal(result, "needs_retry");
});
test("needs_human_review when retries exhausted", () => {
const result = decidePasswordUpdateOutcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 429, {}, 118, 3
);
assert.equal(result, "needs_human_review");
});
test("needs_human_review on persistent complexity error", () => {
const body = { title: "The password does not meet complexity requirements." };
const result = decidePasswordUpdateOutcome(
"2026-07-10T10:00:00Z", "2026-07-10T10:00:00Z", 400, body, 118, 0
);
assert.equal(result, "needs_human_review");
});
Case studies
The store that force-reset ten thousand passwords in one batch
A store migrating off a legacy password scheme queued a script to set a temporary password for every customer, then flip force_password_reset on. Roughly one in every few hundred calls came back with a 400. The original job logged those as hard failures and moved on, leaving a growing list of customers who supposedly never got their new password.
Once the job started diffing date_modified before flagging anything, most of that list turned out to be false alarms. The 400s were concurrency collisions from the job running at higher parallelism than the endpoint's 3-request ceiling. The password had, in almost every case, already been written. Only a small handful were genuine complexity failures on generated passwords that happened to repeat a recent character sequence, and those went to a human instead of being silently dropped.
The support tool that double-sent reset confirmations
A support team's internal tool let agents set a customer's password directly when a customer called in locked out. Occasionally the tool's own retry logic fired a second identical request after a slow first response, and the second call returned a 400 because the account had just changed and a related state check failed on that array element. The tool showed the agent an error and had them try again, which sometimes triggered force_password_reset a second time and confused the customer with a second reset prompt.
Adding the validate-credentials check before the tool showed any error stopped the double-trigger. If the new password already authenticated, the agent saw a clean success screen instead of a false failure, and nothing was resent.
After this runs, a 400 from PUT /v3/customers never gets treated as the final word. Every non-2xx is re-checked against date_modified or validate-credentials before anything else happens, so a write that actually landed is marked resolved with no resend and no extra reset email, a genuine transient collision gets a bounded, backed-off retry within the 3-concurrent and 10-per-batch limits, and only a persistent, confirmed complexity or history failure ever reaches a human.
FAQ
Why does updating a BigCommerce customer's password sometimes return a 400 for no obvious reason?
PUT /v3/customers is a batch array endpoint, capped at 3 concurrent requests, and it validates each customer object's authentication.new_password against the store's hidden complexity and history rules without exposing those rules through the same response. A 400 can mean the password genuinely failed that check, or it can mean you hit the concurrency ceiling, or it can be a stale error on a retry after the password was already accepted. The HTTP status alone cannot tell these apart.
If I get a 400, is it safe to just resend the same password update?
No. Blindly resubmitting can double-fire force_password_reset side effects or reset emails even when the original write already succeeded. Check date_modified on the customer record, or call validate-credentials with the new password, before deciding to retry. Only retry confirmed still-failed writes, and only for a transient class of error such as 429 or a concurrency collision.
How do I tell a real password complexity failure apart from a throttling collision?
Read the full JSON response body, not just the status code, for a per-item errors or title object versus a data entry. Then check the X-Rate-Limit-Requests-Left and X-Rate-Limit-Time-Reset-Ms headers and how many requests to this endpoint were in flight at once. A persistent 400 on repeated confirmed attempts with no rate limit signal is a genuine complexity or history rule failure and belongs with a human, not an automatic retry.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: API status codes reference. developer.bigcommerce.com status codes
- BigCommerce Developer Center: error handling guidance for the REST APIs. developer.bigcommerce.com error handling
- BigCommerce Support: 207 responses on batch product and update calls. support.bigcommerce.com 207 response on batch API
On the solution:
- BigCommerce API Reference: Update Customers (v3), the batch array shape. docs.bigcommerce.com update customers v3
- BigCommerce Developer Center: Validate Credentials endpoint. developer.bigcommerce.com validate credentials
- BigCommerce Developer Center: API rate limits and concurrency guidance. developer.bigcommerce.com API rate limits
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or customer accounts that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this stop a false alarm?
If this saved you from resending a password that already worked, or caught a real complexity failure you would have otherwise missed, 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