Diagnostic Customers

Customer address create no-ops on an exact duplicate with a 200 response

A sync job, an import, or a checkout retry posts a new customer address with POST /v3/customers/addresses, and BigCommerce answers with a clean 200. Everything about the response looks like success. Except the address you meant to add was never created, because it already existed, matched field for field, and BigCommerce quietly deduped it instead of erroring or telling you. No id comes back. Nothing in the payload says "no-op." Here is why that gap opens up and a small script that catches it with a before and after snapshot instead of trusting the status code.

Python and Node.js BigCommerce V3 Customer Addresses API Safe by default (dry run)
Group of people standing in front of people
Photo by adrianna geo on Unsplash
The short answer

BigCommerce's V3 Customer Addresses endpoint treats a set of core fields, first_name, last_name, company, phone, address_type, address1, address2, city, country_code, state_or_province, and postal_code, as a uniqueness key per customer. When a POST /v3/customers/addresses payload matches an existing address on every one of these fields, BigCommerce makes no change and returns a 200 or 207 success, but the address is omitted from the response body's data, so no new address id ever comes back. Snapshot the customer's addresses with GET /v3/customers/addresses?customer_id:in={customer_id} before the write, POST the new address, then re-GET and diff meta.pagination.total and the set of address ids against the pre-write snapshot. If the total is unchanged and no new id appears despite the 200, that is the confirmed silent no-op. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce's V3 Customers Addresses API lets a store keep a list of saved addresses per customer, the kind a shopper builds up over repeat orders shipped to the same handful of places. When you call POST /v3/customers/addresses to add one, BigCommerce first checks whether an address with exactly the same core fields already exists for that customer. If it does, BigCommerce does not create a second, identical record, and it does not touch the existing one either.

That decision is reasonable on its own. Nobody wants five copies of the same shipping address cluttering a customer's account. The problem is how BigCommerce reports it. Instead of an error that says "this address already exists" or a response that hands back the matched record's id, the API returns a 200 or 207, the same status code you would get on a genuine successful create, but the deduped address is simply missing from the response body's data. An integration written to assume "200 means a new address was persisted, and the id is in the response" will misreport the operation every time this happens, and its own bookkeeping will quietly drift out of sync with what BigCommerce's address list actually contains.

POST address matches an existing one Uniqueness check 11 fields, exact match deduped, no id 200 / 207 OK data omits address address_id never returned
BigCommerce answers with success either way. Only the missing id, and an unchanged address count on a re-GET, reveal that nothing was actually created.

Why it happens

A few things about how the Customer Addresses v3 endpoint is built make this the expected shape of the response, not a bug:

The key insight

A 200 from POST /v3/customers/addresses is not proof that anything was created. The address count is. So the safe pattern is not "trust the status code," it is "snapshot before, write, then snapshot after, and diff." We treat GET /v3/customers/addresses?customer_id:in={customer_id}, indexed by the same uniqueness fields BigCommerce itself uses, as the source of truth, and we classify the write as a real create only when a new address id actually shows up in the post-write snapshot. This is not a data-repair scenario, the existing address is already correct, so the only safe action on a confirmed no-op is to flag it for a human, never to retry the identical POST or attempt to force it through.

The fix, as a flow

We do not change how addresses get created. We wrap the create call with a before and after read of the customer's address list, and a pure function that classifies what actually happened from those two snapshots plus the POST response, instead of trusting the status code alone.

Pre-write snapshot GET addresses, index ids POST address capture status and data Classify (pure fn) created / no-op / error? created no-op, flag for review Post-write snapshot re-GET, diff ids and total confirmed created or no-op
The pure classification function only compares snapshots and the response. The actual GET/POST calls happen outside it, one pre-write, one create, one post-write.

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 read and create addresses. 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, no writes happen until you flip this
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, no writes happen until you flip this
2

Talk to the V3 Customer Addresses 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 POST and returns the parsed JSON body along with the status code, since we need to inspect a 200 body closely rather than just trust it.

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)
    r.raise_for_status()
    return r.json() if r.text else {"data": [], "meta": {"pagination": {"total": 0}}}

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 {})
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 });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : { data: [], meta: { pagination: { total: 0 } } };
}

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) : {}];
}
3

Snapshot the customer's addresses before you write

Call GET /v3/customers/addresses?customer_id:in={customer_id}&limit=250, paginating through meta.pagination.links.next if there is more than one page. Build a snapshot of the current address ids and the total, so the post-write comparison has something real to diff against.

step3.py
def snapshot_addresses(customer_id):
    ids = set()
    page = 1
    while True:
        body = bc_get("/customers/addresses", {
            "customer_id:in": customer_id,
            "page": page,
            "limit": 250,
        })
        data = body.get("data") or []
        for addr in data:
            ids.add(addr["id"])
        pagination = (body.get("meta") or {}).get("pagination") or {}
        total = pagination.get("total", len(ids))
        if not data or not pagination.get("links", {}).get("next"):
            return {"ids": ids, "total": total}
        page += 1
step3.js
async function snapshotAddresses(customerId) {
  const ids = new Set();
  let page = 1;
  while (true) {
    const body = await bcGet("/customers/addresses", {
      "customer_id:in": customerId,
      page,
      limit: 250,
    });
    const data = body.data || [];
    for (const addr of data) ids.add(addr.id);
    const pagination = (body.meta || {}).pagination || {};
    const total = pagination.total ?? ids.size;
    if (!data.length || !pagination.links || !pagination.links.next) {
      return { ids, total };
    }
    page += 1;
  }
}
4

Decide, with one pure function

Keep the classification in its own function that takes only the pre-write snapshot, the POST response, and the post-write snapshot, plain values in, plain string out. It never makes a network call itself. A non-2xx status is an error. A 2xx with an empty or id-less data, an unchanged total, and no new id in the post-write snapshot is a silent no-op. Anything else, a new id genuinely appearing, is a real create.

decide.py
from typing import Literal

def classify_address_create_result(
    pre_snapshot: dict, post_response: dict, post_snapshot: dict
) -> Literal["created", "silent_noop", "error"]:
    status = post_response.get("status")
    if status is None or status >= 400:
        return "error"

    data = post_response.get("data")
    data_has_id = bool(data) and (
        (isinstance(data, dict) and data.get("id") is not None)
        or (isinstance(data, list) and len(data) > 0 and any(
            isinstance(item, dict) and item.get("id") is not None for item in data
        ))
    )

    pre_ids = pre_snapshot.get("ids") or set()
    post_ids = post_snapshot.get("ids") or set()
    new_ids = post_ids - pre_ids

    total_unchanged = post_snapshot.get("total") == pre_snapshot.get("total")
    ids_unchanged = post_ids.issubset(pre_ids) and not new_ids

    if not data_has_id and total_unchanged and ids_unchanged:
        return "silent_noop"

    return "created"
decide.js
export function classifyAddressCreateResult(preSnapshot, postResponse, postSnapshot) {
  const status = postResponse.status;
  if (status === undefined || status === null || status >= 400) return "error";

  const data = postResponse.data;
  const dataHasId =
    (Array.isArray(data) && data.some((item) => item && typeof item === "object" && item.id != null)) ||
    (data && typeof data === "object" && !Array.isArray(data) && data.id != null);

  const preIds = preSnapshot.ids || new Set();
  const postIds = postSnapshot.ids || new Set();
  const newIds = [...postIds].filter((id) => !preIds.has(id));

  const totalUnchanged = postSnapshot.total === preSnapshot.total;
  const idsUnchanged = [...postIds].every((id) => preIds.has(id)) && newIds.length === 0;

  if (!dataHasId && totalUnchanged && idsUnchanged) return "silent_noop";

  return "created";
}
5

Find the matched existing address for the report

When the classification comes back silent_noop, the report is more useful with the existing address id BigCommerce actually deduped against. Build a normalized uniqueness key from the same 11 fields BigCommerce uses, index the pre-write snapshot's full address records by that key, and look up the attempted payload's key in that index.

match.py
UNIQUENESS_FIELDS = [
    "first_name", "last_name", "company", "phone", "address_type",
    "address1", "address2", "city", "country_code", "state_or_province", "postal_code",
]

def uniqueness_key(fields):
    return tuple((fields.get(f) or "").strip().lower() for f in UNIQUENESS_FIELDS)

def find_matched_address_id(existing_addresses, attempted_fields):
    target = uniqueness_key(attempted_fields)
    for addr in existing_addresses:
        if uniqueness_key(addr) == target:
            return addr["id"]
    return None
match.js
const UNIQUENESS_FIELDS = [
  "first_name", "last_name", "company", "phone", "address_type",
  "address1", "address2", "city", "country_code", "state_or_province", "postal_code",
];

function uniquenessKey(fields) {
  return UNIQUENESS_FIELDS.map((f) => String(fields[f] || "").trim().toLowerCase()).join("|");
}

function findMatchedAddressId(existingAddresses, attemptedFields) {
  const target = uniquenessKey(attemptedFields);
  for (const addr of existingAddresses) {
    if (uniquenessKey(addr) === target) return addr.id;
  }
  return null;
}
6

Wire it together with a dry run guard

The run function snapshots before, posts the address, snapshots after, classifies the result, and logs accordingly. On silent_noop, it logs a structured warning with the customer id, the attempted fields, and the matched existing address id, and never retries the POST. DRY_RUN defaults to true. When true, it only reads and reports, no address is ever posted, which is the safest way to run this against a real store the first few times.

Run it safe

Never retry an identical POST after a confirmed silent no-op, and never attempt a PUT to force a duplicate through. BigCommerce has no mechanism for that, and the fields defining uniqueness are effectively immutable for this check. If the caller genuinely intends a distinct address, surface it for a human to adjust a field like the unit or suite, do not automate around it.

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 only ever reports a silent no-op, it never retries the create or writes anything to correct it, since there is no bad state to repair.

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

detect_address_noop.py
"""Detect a silent no-op when POST /v3/customers/addresses matches a duplicate.

BigCommerce's V3 Customer Addresses endpoint treats first_name, last_name,
company, phone, address_type, address1, address2, city, country_code,
state_or_province, and postal_code as a uniqueness key per customer. When a
POST matches an existing address on all of these fields, BigCommerce makes no
change to the existing record and returns a 200 or 207 success, but the
address is omitted from the response body's data, so no new address id is
ever returned. An integration that assumes 200 means "created, id returned"
will misreport the operation and drift out of sync with the store's real
address list. This script snapshots a customer's addresses before the write,
posts the new address, snapshots again, and classifies the result with a pure
function. A confirmed silent no-op is flagged and reported with the matched
existing address id, never retried, since there is no bad state to repair.

Guide: https://www.allanninal.dev/bigcommerce/duplicate-address-create-silent-noop/
"""
import os
import logging
from typing import Literal

import requests

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

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"

UNIQUENESS_FIELDS = [
    "first_name", "last_name", "company", "phone", "address_type",
    "address1", "address2", "city", "country_code", "state_or_province", "postal_code",
]

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)
    r.raise_for_status()
    if not r.text:
        return {"data": [], "meta": {"pagination": {"total": 0}}}
    return r.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 uniqueness_key(fields):
    return tuple((fields.get(f) or "").strip().lower() for f in UNIQUENESS_FIELDS)


def find_matched_address_id(existing_addresses, attempted_fields):
    target = uniqueness_key(attempted_fields)
    for addr in existing_addresses:
        if uniqueness_key(addr) == target:
            return addr["id"]
    return None


def snapshot_addresses(customer_id):
    """Page through GET /v3/customers/addresses for one customer.

    Returns {"ids": set(...), "total": int, "records": [...]} so callers can
    both diff by id/total and look up the matched record's full fields.
    """
    ids = set()
    records = []
    page = 1
    total = 0
    while True:
        body = bc_get(
            "/customers/addresses",
            {"customer_id:in": customer_id, "page": page, "limit": 250},
        )
        data = body.get("data") or []
        for addr in data:
            ids.add(addr["id"])
            records.append(addr)
        pagination = (body.get("meta") or {}).get("pagination") or {}
        total = pagination.get("total", len(ids))
        next_link = (pagination.get("links") or {}).get("next")
        if not data or not next_link:
            break
        page += 1
    return {"ids": ids, "total": total, "records": records}


def classify_address_create_result(
    pre_snapshot: dict, post_response: dict, post_snapshot: dict
) -> Literal["created", "silent_noop", "error"]:
    """Pure decision. No network, no side effects.

    if post_response["status"] >= 400: error.
    Else if the response has no address id in data, and the post-write
    snapshot's total and id set are unchanged from the pre-write snapshot,
    silent_noop. Otherwise a new id appeared, or data has an id: created.
    """
    status = post_response.get("status")
    if status is None or status >= 400:
        return "error"

    data = post_response.get("data")
    data_has_id = bool(data) and (
        (isinstance(data, dict) and data.get("id") is not None)
        or (
            isinstance(data, list)
            and len(data) > 0
            and any(isinstance(item, dict) and item.get("id") is not None for item in data)
        )
    )

    pre_ids = pre_snapshot.get("ids") or set()
    post_ids = post_snapshot.get("ids") or set()
    new_ids = post_ids - pre_ids

    total_unchanged = post_snapshot.get("total") == pre_snapshot.get("total")
    ids_unchanged = post_ids.issubset(pre_ids) and not new_ids

    if not data_has_id and total_unchanged and ids_unchanged:
        return "silent_noop"

    return "created"


def create_customer_address(address_fields):
    return bc_post("/customers/addresses", [address_fields])


def run(customer_id, address_fields):
    pre_snapshot = snapshot_addresses(customer_id)

    if DRY_RUN:
        log.info(
            "DRY_RUN: would POST address for customer_id=%s. Skipping write, "
            "pre_snapshot_total=%s",
            customer_id, pre_snapshot["total"],
        )
        return "dry_run"

    status, body = bc_post("/customers/addresses", [address_fields])
    post_response = {"status": status, "data": body.get("data")}

    post_snapshot = snapshot_addresses(customer_id)
    decision = classify_address_create_result(pre_snapshot, post_response, post_snapshot)

    if decision == "error":
        log.error(
            "Address create failed. customer_id=%s status=%s body=%s",
            customer_id, status, body,
        )
        return decision

    if decision == "silent_noop":
        matched_id = find_matched_address_id(pre_snapshot["records"], address_fields)
        log.warning(
            "address_create_silent_noop: exact duplicate already existed, no new "
            "address_id created. customer_id=%s matched_existing_address_id=%s "
            "attempted_fields=%s",
            customer_id, matched_id, address_fields,
        )
        return decision

    log.info("Address created for customer_id=%s. total now %s", customer_id, post_snapshot["total"])
    return decision


if __name__ == "__main__":
    run(
        customer_id=123,
        address_fields={
            "first_name": "Jamie",
            "last_name": "Rivera",
            "address1": "123 Main St",
            "city": "Austin",
            "country_code": "US",
            "state_or_province": "Texas",
            "postal_code": "78701",
        },
    )
detect-address-noop.js
/**
 * Detect a silent no-op when POST /v3/customers/addresses matches a duplicate.
 *
 * BigCommerce's V3 Customer Addresses endpoint treats first_name, last_name,
 * company, phone, address_type, address1, address2, city, country_code,
 * state_or_province, and postal_code as a uniqueness key per customer. When a
 * POST matches an existing address on all of these fields, BigCommerce makes
 * no change to the existing record and returns a 200 or 207 success, but the
 * address is omitted from the response body's data, so no new address id is
 * ever returned. An integration that assumes 200 means "created, id returned"
 * will misreport the operation and drift out of sync with the store's real
 * address list. This script snapshots a customer's addresses before the
 * write, posts the new address, snapshots again, and classifies the result
 * with a pure function. A confirmed silent no-op is flagged and reported
 * with the matched existing address id, never retried, since there is no bad
 * state to repair.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/duplicate-address-create-silent-noop/
 */
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 UNIQUENESS_FIELDS = [
  "first_name", "last_name", "company", "phone", "address_type",
  "address1", "address2", "city", "country_code", "state_or_province", "postal_code",
];

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

function uniquenessKey(fields) {
  return UNIQUENESS_FIELDS.map((f) => String(fields[f] || "").trim().toLowerCase()).join("|");
}

function findMatchedAddressId(existingAddresses, attemptedFields) {
  const target = uniquenessKey(attemptedFields);
  for (const addr of existingAddresses) {
    if (uniquenessKey(addr) === target) return addr.id;
  }
  return null;
}

/**
 * Pure decision. No network, no side effects.
 *
 * if postResponse.status >= 400: "error".
 * Else if the response has no address id in data, and the post-write
 * snapshot's total and id set are unchanged from the pre-write snapshot,
 * "silent_noop". Otherwise a new id appeared, or data has an id: "created".
 */
export function classifyAddressCreateResult(preSnapshot, postResponse, postSnapshot) {
  const status = postResponse.status;
  if (status === undefined || status === null || status >= 400) return "error";

  const data = postResponse.data;
  const dataHasId =
    (Array.isArray(data) && data.some((item) => item && typeof item === "object" && item.id != null)) ||
    (data && typeof data === "object" && !Array.isArray(data) && data.id != null);

  const preIds = preSnapshot.ids || new Set();
  const postIds = postSnapshot.ids || new Set();
  const newIds = [...postIds].filter((id) => !preIds.has(id));

  const totalUnchanged = postSnapshot.total === preSnapshot.total;
  const idsUnchanged = [...postIds].every((id) => preIds.has(id)) && newIds.length === 0;

  if (!dataHasId && totalUnchanged && idsUnchanged) return "silent_noop";

  return "created";
}

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: [], meta: { pagination: { total: 0 } } };
}

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 snapshotAddresses(customerId) {
  const ids = new Set();
  const records = [];
  let page = 1;
  let total = 0;
  while (true) {
    const body = await bcGet("/customers/addresses", {
      "customer_id:in": customerId,
      page,
      limit: 250,
    });
    const data = body.data || [];
    for (const addr of data) {
      ids.add(addr.id);
      records.push(addr);
    }
    const pagination = (body.meta || {}).pagination || {};
    total = pagination.total ?? ids.size;
    const nextLink = (pagination.links || {}).next;
    if (!data.length || !nextLink) break;
    page += 1;
  }
  return { ids, total, records };
}

async function createCustomerAddress(addressFields) {
  return bcPost("/customers/addresses", [addressFields]);
}

export async function run(customerId, addressFields) {
  const preSnapshot = await snapshotAddresses(customerId);

  if (DRY_RUN) {
    console.log(
      `DRY_RUN: would POST address for customer_id=${customerId}. Skipping write, ` +
      `pre_snapshot_total=${preSnapshot.total}`
    );
    return "dry_run";
  }

  const [status, body] = await createCustomerAddress(addressFields);
  const postResponse = { status, data: body.data };

  const postSnapshot = await snapshotAddresses(customerId);
  const decision = classifyAddressCreateResult(preSnapshot, postResponse, postSnapshot);

  if (decision === "error") {
    console.error(`Address create failed. customer_id=${customerId} status=${status} body=${JSON.stringify(body)}`);
    return decision;
  }

  if (decision === "silent_noop") {
    const matchedId = findMatchedAddressId(preSnapshot.records, addressFields);
    console.warn(
      "address_create_silent_noop: exact duplicate already existed, no new " +
      `address_id created. customer_id=${customerId} matched_existing_address_id=${matchedId} ` +
      `attempted_fields=${JSON.stringify(addressFields)}`
    );
    return decision;
  }

  console.log(`Address created for customer_id=${customerId}. total now ${postSnapshot.total}`);
  return decision;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run(123, {
    first_name: "Jamie",
    last_name: "Rivera",
    address1: "123 Main St",
    city: "Austin",
    country_code: "US",
    state_or_province: "Texas",
    postal_code: "78701",
  }).catch((err) => {
    console.error(err);
    process.exit(1);
  });
}

Add a test

The classification function is the part most worth testing, because it decides whether the script reports a real create or catches a silent no-op. Because classify_address_create_result takes only plain snapshots and a plain response, the test needs no network and no BigCommerce store. It just feeds in fixture snapshots and checks the answer.

test_duplicate_address_noop.py
from detect_address_noop import classify_address_create_result, find_matched_address_id


def snapshot(ids, total=None):
    id_set = set(ids)
    return {"ids": id_set, "total": total if total is not None else len(id_set)}


def test_created_when_a_new_id_appears():
    pre = snapshot([1, 2])
    post = snapshot([1, 2, 3])
    response = {"status": 201, "data": {"id": 3}}
    assert classify_address_create_result(pre, response, post) == "created"


def test_silent_noop_when_total_and_ids_unchanged_and_no_id_in_data():
    pre = snapshot([1, 2])
    post = snapshot([1, 2])
    response = {"status": 200, "data": []}
    assert classify_address_create_result(pre, response, post) == "silent_noop"


def test_silent_noop_when_data_is_empty_object():
    pre = snapshot([1, 2])
    post = snapshot([1, 2])
    response = {"status": 207, "data": {}}
    assert classify_address_create_result(pre, response, post) == "silent_noop"


def test_error_on_4xx_status():
    pre = snapshot([1, 2])
    post = snapshot([1, 2])
    response = {"status": 422, "data": {}}
    assert classify_address_create_result(pre, response, post) == "error"


def test_created_when_data_has_id_even_if_totals_look_equal():
    # Defensive: if the response itself carries an id, trust it as created.
    pre = snapshot([1, 2])
    post = snapshot([1, 2, 3])
    response = {"status": 200, "data": [{"id": 3}]}
    assert classify_address_create_result(pre, response, post) == "created"


def test_find_matched_address_id_returns_the_matching_existing_record():
    existing = [
        {"id": 55, "first_name": "Jamie", "last_name": "Rivera", "company": "",
         "phone": "", "address_type": "residential", "address1": "123 Main St",
         "address2": "", "city": "Austin", "country_code": "US",
         "state_or_province": "Texas", "postal_code": "78701"},
    ]
    attempted = {
        "first_name": "Jamie", "last_name": "Rivera", "address1": "123 Main St",
        "city": "Austin", "country_code": "US", "state_or_province": "Texas",
        "postal_code": "78701",
    }
    assert find_matched_address_id(existing, attempted) == 55


def test_find_matched_address_id_returns_none_when_no_match():
    existing = [
        {"id": 55, "first_name": "Jamie", "last_name": "Rivera", "address1": "123 Main St",
         "city": "Austin", "country_code": "US", "state_or_province": "Texas", "postal_code": "78701"},
    ]
    attempted = {"first_name": "Alex", "last_name": "Nguyen", "address1": "9 Other Ave",
                 "city": "Dallas", "country_code": "US", "state_or_province": "Texas", "postal_code": "75001"}
    assert find_matched_address_id(existing, attempted) is None
detect-address-noop.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyAddressCreateResult } from "./detect-address-noop.js";

const snapshot = (ids, total) => {
  const idSet = new Set(ids);
  return { ids: idSet, total: total !== undefined ? total : idSet.size };
};

test("created when a new id appears", () => {
  const pre = snapshot([1, 2]);
  const post = snapshot([1, 2, 3]);
  const response = { status: 201, data: { id: 3 } };
  assert.equal(classifyAddressCreateResult(pre, response, post), "created");
});

test("silent_noop when total and ids unchanged and no id in data", () => {
  const pre = snapshot([1, 2]);
  const post = snapshot([1, 2]);
  const response = { status: 200, data: [] };
  assert.equal(classifyAddressCreateResult(pre, response, post), "silent_noop");
});

test("silent_noop when data is empty object", () => {
  const pre = snapshot([1, 2]);
  const post = snapshot([1, 2]);
  const response = { status: 207, data: {} };
  assert.equal(classifyAddressCreateResult(pre, response, post), "silent_noop");
});

test("error on 4xx status", () => {
  const pre = snapshot([1, 2]);
  const post = snapshot([1, 2]);
  const response = { status: 422, data: {} };
  assert.equal(classifyAddressCreateResult(pre, response, post), "error");
});

test("created when data has id even if totals look equal", () => {
  const pre = snapshot([1, 2]);
  const post = snapshot([1, 2, 3]);
  const response = { status: 200, data: [{ id: 3 }] };
  assert.equal(classifyAddressCreateResult(pre, response, post), "created");
});

Case studies

Address book import

The migration script that thought it re-created everything

A store migrated customer address books from a legacy platform. The import ran twice, once as a dry pass to check for errors, once for real, both times reading each address from the same export file. The second run's log showed a clean 200 for every single address, and the job reported a full success count matching the number of rows in the file.

Only a manual spot check in the BigCommerce admin caught it. The address totals per customer had not moved between the two runs. Every one of the "created" addresses in the second pass was a silent no-op against what the first pass had already created. Adding the before and after snapshot diff turned that invisible gap into an explicit, logged no-op count the team could see on every run going forward.

Checkout retry

The storefront that quietly stopped saving a second address

A returning shopper's checkout flow saved their shipping address to their account on every order, so a repeat order to the same address would post the same fields again. The team assumed each checkout was adding a fresh row, since the endpoint always answered 200, and could not explain why the customer's saved address count in a support ticket did not match the number of orders they had placed.

Running the classification against a snapshot before and after the save confirmed it was never creating a new record for a repeat address, it was deduping every time, exactly as documented. The team stopped counting saved addresses as a proxy for order count and used the real order history instead.

What good looks like

After this check is in place, a 200 from POST /v3/customers/addresses is never taken at face value. Every write is measured against a real before and after snapshot, a genuine create is confirmed by a new id actually appearing, and a silent no-op is logged with the matched existing address id instead of being miscounted as a new row. Nothing is auto-repaired, because there is nothing broken to repair, the existing address was already correct all along.

FAQ

Why does POST /v3/customers/addresses return 200 but not create a new address?

BigCommerce treats a set of core fields, first_name, last_name, company, phone, address_type, address1, address2, city, country_code, state_or_province, and postal_code, as a uniqueness key per customer. When a new address submission matches an existing address on all of these fields, BigCommerce intentionally dedupes rather than erroring. It makes no change to the existing record and returns a 200 or 207 success, but the address is omitted from the response body's data, so no new address id is ever returned.

How do I tell a silent no-op apart from a real address create?

Snapshot the customer's addresses with GET /v3/customers/addresses?customer_id:in={customer_id} before the POST, indexed by a normalized uniqueness key. After the POST, check whether the response status is 2xx with an empty or id-less data, then re-GET the same endpoint and diff meta.pagination.total and the set of address ids against the pre-write snapshot. If the total is unchanged and no new id appears despite the 200, that is the confirmed silent no-op signature.

Should I retry the POST or try to force the duplicate address to save?

No. The POST already succeeded from BigCommerce's perspective, since deduping by design is not an error condition, so there is nothing to correct with a retry. Do not attempt a PUT to force a duplicate either, BigCommerce has no mechanism for that and the uniqueness fields are effectively immutable for this check. Flag the (customer_id, attempted fields) pair with the matched existing address id and surface it for human review instead.

Related field notes

Citations

On the problem:

  1. BigCommerce API Reference: Create Customers Addresses (POST /v3/customers/addresses). docs.bigcommerce.com create customers addresses
  2. BigCommerce Developer Center: Customer Addresses reference and uniqueness fields. developer.bigcommerce.com customer addresses
  3. BigCommerce Help Center: the API allowing duplicate customers/addresses. support.bigcommerce.com API allows duplicates

On the solution:

  1. BigCommerce API Reference: Create Customers Addresses (response shape). docs.bigcommerce.com create customers addresses
  2. BigCommerce API Reference: List Customers Addresses (GET, pagination). docs.bigcommerce.com list customers addresses
  3. BigCommerce Developer Center: Customers v3 API reference. developer.bigcommerce.com customers v3

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 catch a silent no-op for you?

If this saved you from an address import that quietly drifted out of sync, 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