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.

Python and Node.js BigCommerce Customers V3 API Safe by default (dry run)
Two women in uniform standing near a car
Photo by blue sky on Unsplash
The short answer

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.

Script sends PUT /v3/customers [ ] Hidden password rules and 3 concurrent cap checked server side complexity, history, or concurrency 400 for that item inside data/errors Script only reads status code looks random
A 400 on this endpoint can be a genuine password rule failure, a concurrency collision, or a stale retry error after the write already succeeded. The status code alone cannot tell them apart.

Why it happens

A few concrete ways this shows up in a real integration:

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 key insight

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.

PUT /v3/customers array of 1, password Diff date_modified pre call vs post call validate-credentials ground truth check Advanced or confirmed? yes no, check status class confirmed_success no resend, no email
The script never trusts the raw status code. It confirms the write with date_modified or validate-credentials first, then only retries a confirmed transient failure, and only reports a confirmed persistent one.

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 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.

setup (shell)
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
setup (shell)
// 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
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 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.

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_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
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 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];
}
3

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}}].

step3.py
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)
step3.js
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);
}
4

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.

decide.py
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
decide.js
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;
}
5

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.

confirm.py
def validate_credentials(email, password):
    status, body = bc_get_post_validate("/customers/validate-credentials", {
        "email": email,
        "password": password,
    })
    return status == 200
confirm.js
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;
}
6

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.

Run it safe

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.

recheck_password_updates.py
"""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()
recheck-password-updates.js
/**
 * 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.

test_customer_password_decision.py
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"
recheck-password-updates.test.js
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

Password reset migration

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.

Support-driven password reset

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.

What good looks like

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:

  1. BigCommerce Developer Center: API status codes reference. developer.bigcommerce.com status codes
  2. BigCommerce Developer Center: error handling guidance for the REST APIs. developer.bigcommerce.com error handling
  3. BigCommerce Support: 207 responses on batch product and update calls. support.bigcommerce.com 207 response on batch API

On the solution:

  1. BigCommerce API Reference: Update Customers (v3), the batch array shape. docs.bigcommerce.com update customers v3
  2. BigCommerce Developer Center: Validate Credentials endpoint. developer.bigcommerce.com validate credentials
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes