Diagnostic Orders

422 fulfillment address incomplete despite address looking complete

The consignment call succeeded. The checkout looks fully addressed. Then placing the order comes back 422, a fulfillment address for this order is incomplete. BigCommerce validates fulfillment addresses far more strictly at order-creation time than the create-consignment call ever hinted at, so one missing leaf key slips through unnoticed until it blocks the order. Here is why that gap opens up and a script that names the exact missing subfield per order or checkout.

Python and Node.js BigCommerce V3 Checkout and Orders API Detect and report, no auto-fix
Two smiling men holding "service &" and "expertise" signs.
Photo by Md Ishak Rahman on Unsplash
The short answer

A consignment created with POST /v3/checkouts/{checkoutId}/consignments only strictly requires the address to carry email and country_code plus lineItems. That call succeeds with a partial address, so integrators assume the address is complete. But placing the order with POST /v3/orders, or completing the checkout, requires a fuller set of subfields on the address: first_name, last_name, address1, city, state_or_province_code, postal_code, country_code, and phone. The 422 only surfaces at that later step, and the missing key, commonly state_or_province_code, postal_code, or phone, is easy to miss because the address object itself is present. Run a small Python or Node.js function that checks a stored or in-flight address against the full required-key set and reports the exact missing or invalid field per order or checkout id, so nothing gets auto-guessed. Full code, tests, and citations are below.

The problem in plain words

BigCommerce's checkout flow is built in stages, and each stage validates a different, larger slice of the address. When you create a consignment on a checkout with POST /v3/checkouts/{checkoutId}/consignments, the API is lenient. It needs an address object with email and country_code, and a lineItems array, and it will happily accept that even if state_or_province_code or postal_code or phone is missing or blank.

That success response is misleading. It tells integrators the address was accepted, so the natural assumption is that the address is good to go. Then, at the point where the order actually gets placed, either through POST /v3/orders directly or by completing the checkout, BigCommerce runs a much stricter validation against the same address object. It now requires first_name, last_name, address1, city, state_or_province_code (or state_or_province), postal_code, country_code, and phone. If even one of those is an empty string, null, or simply absent, the response is a 422 with a message like "A fulfillment address for this order is incomplete", and the address object that looked complete a moment ago turns out to have one quiet gap.

POST consignments partial address, 201 Looks complete address object present 1 leaf key missing POST v3/orders full address check 422 incomplete
The consignment call and the order-placement call validate the same address object against two very different key sets. The gap between them is where the 422 hides.

Why it happens

BigCommerce splits address validation across stages of checkout on purpose, to let a checkout start before the customer has typed a full address. A few common ways that split ends up surfacing as a confusing 422:

This exact confusion, an address that "looks" filled in but still gets rejected, is a recurring theme in BigCommerce's own support threads. See the citations at the end for the specific cases.

The key insight

A successful POST /v3/checkouts/{checkoutId}/consignments call is not proof the address is complete. It only proves email, country_code, and lineItems were present. The address that will actually place an order needs every key in a longer list: first_name, last_name, address1, city, state_or_province_code, postal_code, country_code, phone. So the safe pattern is not "trust the consignment response." It is "validate the address object against the full required-key set before you ever call POST /v3/orders, and log the exact key that is missing, so a 422 message that just says incomplete becomes a specific, actionable field name."

The fix, as a flow

We do not touch the checkout or order-creation flow itself. We add a check that runs the stored or in-flight address through a pure function before the order-placement call, and reports precisely which key would trip the 422 instead of finding out from the 422 response itself.

List candidates status_id 0 or 11 Fetch address shippingaddresses or checkout Check required keys with alias handling Any key missing? yes no, complete Safe to place POST v3/orders Flag order id + missing field(s)
The check runs before order placement and names the exact missing field, instead of letting a bare 422 surface at checkout complete.

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 Orders (modify) and Checkouts (modify) scope so it can read order shipping addresses and, if applicable, checkout consignments. 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 ORDER_STATUS_IDS="0,11"
export DRY_RUN="true"   # start safe, change to false to write a normalized field
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export ORDER_STATUS_IDS="0,11"
export DRY_RUN="true"   // start safe, change to false to write a normalized field
2

Talk to the V2 Orders and V3 Checkouts REST APIs

Order shipping addresses live under the V2 Orders API at https://api.bigcommerce.com/stores/{store_hash}/v2/. In-flight checkouts live under the V3 API at https://api.bigcommerce.com/stores/{store_hash}/v3/. Both use the same X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = 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(base, path, params=None):
    r = requests.get(f"{base}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json() if r.text else []

def bc_put(base, path, body):
    r = requests.put(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `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(base, path, params = {}) {
  const url = new URL(`${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) : [];
}

async function bcPut(base, path, body) {
  const res = await fetch(`${base}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List candidate orders and fetch their stored shipping address

Call GET /v2/orders?status_id=11 (Awaiting Fulfillment) or status_id=0 (Incomplete) to find candidates, or replay from your own pre-submit queue. For each order, call GET /v2/orders/{order_id}/shippingaddresses to get the address BigCommerce actually stored: first_name, last_name, street_1, street_2, city, state, zip, country, country_iso2, phone, email. For an in-flight checkout, call GET /v3/checkouts/{checkoutId} and read consignments[].shipping_address or billing_address instead.

step3.py
def candidate_orders(status_ids):
    page = 1
    while True:
        found_any = False
        for status_id in status_ids:
            orders = bc_get(API_BASE_V2, "/orders", {"status_id": status_id, "page": page, "limit": 50})
            for order in orders:
                found_any = True
                yield order
        if not found_any:
            return
        page += 1

def order_shipping_addresses(order_id):
    return bc_get(API_BASE_V2, f"/orders/{order_id}/shippingaddresses")

def checkout_addresses(checkout_id):
    checkout = bc_get(API_BASE_V3, f"/checkouts/{checkout_id}")
    consignments = (checkout.get("data") or {}).get("consignments") or []
    return [c.get("shipping_address") for c in consignments if c.get("shipping_address")]
step3.js
async function* candidateOrders(statusIds) {
  let page = 1;
  while (true) {
    let foundAny = false;
    for (const statusId of statusIds) {
      const orders = await bcGet(API_BASE_V2, "/orders", { status_id: statusId, page, limit: 50 });
      for (const order of orders) {
        foundAny = true;
        yield order;
      }
    }
    if (!foundAny) return;
    page += 1;
  }
}

async function orderShippingAddresses(orderId) {
  return bcGet(API_BASE_V2, `/orders/${orderId}/shippingaddresses`);
}

async function checkoutAddresses(checkoutId) {
  const checkout = await bcGet(API_BASE_V3, `/checkouts/${checkoutId}`);
  const consignments = (checkout.data || {}).consignments || [];
  return consignments.map((c) => c.shipping_address).filter(Boolean);
}
4

Decide, with one pure function

Keep the decision in its own function that takes an address object and the required-key/alias table, and returns the ordered list of keys that are missing or invalid. It accepts known aliases, since V2 shipping addresses and V3 checkout addresses do not always share exact field names: address1 or street_1, postal_code or zip, country_code or country_iso2, state_or_province_code or state. Empty string, null, or a missing key all count as failing. country_code/country_iso2 is also checked against a 2-letter alpha pattern, since BigCommerce requires the pair to resolve to a real ISO-3166 country.

decide.py
import re

REQUIRED_KEYS = [
    "first_name", "last_name", "address1", "city",
    "state_or_province_code", "postal_code", "country_code", "phone",
]

ALIASES = {
    "address1": ["address1", "street_1"],
    "postal_code": ["postal_code", "zip"],
    "country_code": ["country_code", "country_iso2"],
    "state_or_province_code": ["state_or_province_code", "state_or_province", "state"],
}

COUNTRY_CODE_RE = re.compile(r"^[A-Za-z]{2}$")

def _first_present(address, key):
    for alias in ALIASES.get(key, [key]):
        value = address.get(alias)
        if value is not None and str(value).strip() != "":
            return str(value).strip()
    return None

def find_missing_address_fields(address, required_keys=None):
    required_keys = required_keys or REQUIRED_KEYS
    address = address or {}
    missing = []
    for key in required_keys:
        value = _first_present(address, key)
        if value is None:
            missing.append(key)
            continue
        if key == "country_code" and not COUNTRY_CODE_RE.match(value):
            missing.append(key)
    return missing
decide.js
const REQUIRED_KEYS = [
  "first_name", "last_name", "address1", "city",
  "state_or_province_code", "postal_code", "country_code", "phone",
];

const ALIASES = {
  address1: ["address1", "street_1"],
  postal_code: ["postal_code", "zip"],
  country_code: ["country_code", "country_iso2"],
  state_or_province_code: ["state_or_province_code", "state_or_province", "state"],
};

const COUNTRY_CODE_RE = /^[A-Za-z]{2}$/;

function firstPresent(address, key) {
  for (const alias of ALIASES[key] || [key]) {
    const value = address[alias];
    if (value !== null && value !== undefined && String(value).trim() !== "") {
      return String(value).trim();
    }
  }
  return null;
}

export function findMissingAddressFields(address, requiredKeys = REQUIRED_KEYS) {
  address = address || {};
  const missing = [];
  for (const key of requiredKeys) {
    const value = firstPresent(address, key);
    if (value === null) {
      missing.push(key);
      continue;
    }
    if (key === "country_code" && !COUNTRY_CODE_RE.test(value)) {
      missing.push(key);
    }
  }
  return missing;
}
5

Report, do not blind-write a synthetic address

When find_missing_address_fields returns a non-empty list, emit a report with the order or checkout id, the missing or invalid fields, and a snapshot of the address for manual or customer service follow-up. Do not invent a postal code, phone number, or state. If a corrective write is deterministic, for example normalizing a known-good country name to its country_code, gate that single write behind DRY_RUN and log a before and after diff of just the changed keys.

apply.py
def normalize_country_code(order_id, address_id, address, known_country_map):
    """Only writes when the correct country_code can be deterministically derived
    from a validated country name. Everything else stays flagged, not guessed."""
    country_name = (address.get("country") or "").strip()
    derived = known_country_map.get(country_name.lower())
    if not derived:
        return None  # cannot derive safely, leave flagged

    before = {"country_code": address.get("country_iso2") or address.get("country_code")}
    after = {"country_code": derived}
    if DRY_RUN:
        return {"order_id": order_id, "dry_run": True, "before": before, "after": after}

    bc_put(API_BASE_V2, f"/orders/{order_id}/shippingaddresses/{address_id}", after)
    return {"order_id": order_id, "dry_run": False, "before": before, "after": after}
apply.js
async function normalizeCountryCode(orderId, addressId, address, knownCountryMap) {
  const countryName = (address.country || "").trim().toLowerCase();
  const derived = knownCountryMap[countryName];
  if (!derived) return null; // cannot derive safely, leave flagged

  const before = { country_code: address.country_iso2 || address.country_code };
  const after = { country_code: derived };
  if (DRY_RUN) return { orderId, dryRun: true, before, after };

  await bcPut(API_BASE_V2, `/orders/${orderId}/shippingaddresses/${addressId}`, after);
  return { orderId, dryRun: false, before, after };
}
6

Wire it together with a dry run guard

The loop ties every piece together. For each order or checkout, fetch the address, run it through find_missing_address_fields, and log a report line the moment anything is missing, naming the order id and the exact fields, instead of a bare "address incomplete". Leave DRY_RUN on so the only writes that could ever happen are the narrow, deterministic normalizations you explicitly opt into.

Run it safe

This is a detect-and-report tool by design. Never let it write a postal code, phone number, state code, or any customer-supplied field it cannot deterministically derive from already-validated data. Always start with DRY_RUN=true, and route every flagged order to a human or a customer re-prompt, not to a synthetic address.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs exactly which address field triggered the check, respects the dry run flag, and never guesses a value it cannot deterministically derive.

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

find_incomplete_fulfillment_addresses.py
"""Find BigCommerce orders and checkouts whose fulfillment address looks
complete but is missing a subfield that trips 422 "A fulfillment address for
this order is incomplete" at order-creation or checkout-complete time.

POST /v3/checkouts/{checkoutId}/consignments only strictly requires email and
country_code on the address plus lineItems, so a consignment can be created
successfully with a partial address. POST /v3/orders and checkout complete
validate a fuller set of subfields: first_name, last_name, address1, city,
state_or_province_code, postal_code, country_code, phone. The missing key,
commonly state_or_province_code, postal_code, or phone, or an invalid
country_code/country_iso2, is easy to miss because the address object itself
is present. This job lists candidate orders (status_id 0 or 11), fetches each
stored shipping address, and reports the exact missing or invalid field per
order id. It never invents a value; a missing subfield is customer data this
script cannot safely guess. Only a narrow, deterministic normalization (a
known-good country name to its country_code) is ever written, gated by
DRY_RUN. Run on demand or on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/422-fulfillment-address-incomplete/
"""
import os
import re
import logging

import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE_V2 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v2"
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
ORDER_STATUS_IDS = [s.strip() for s in os.environ.get("ORDER_STATUS_IDS", "0,11").split(",") if s.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

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

REQUIRED_KEYS = [
    "first_name", "last_name", "address1", "city",
    "state_or_province_code", "postal_code", "country_code", "phone",
]

ALIASES = {
    "address1": ["address1", "street_1"],
    "postal_code": ["postal_code", "zip"],
    "country_code": ["country_code", "country_iso2"],
    "state_or_province_code": ["state_or_province_code", "state_or_province", "state"],
}

COUNTRY_CODE_RE = re.compile(r"^[A-Za-z]{2}$")

# Narrow, deterministic country-name to country_code table. Extend only with
# values you have already validated; anything not in here stays flagged.
KNOWN_COUNTRY_MAP = {
    "united states": "US",
    "united states of america": "US",
    "canada": "CA",
    "united kingdom": "GB",
    "australia": "AU",
}


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


def bc_put(base, path, body):
    r = requests.put(f"{base}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def _first_present(address, key):
    for alias in ALIASES.get(key, [key]):
        value = address.get(alias)
        if value is not None and str(value).strip() != "":
            return str(value).strip()
    return None


def find_missing_address_fields(address, required_keys=None):
    """Pure decision. No network, no side effects.

    For each key in required_keys (accepting known aliases), check that the
    address has a non-empty value under that key or one of its aliases.
    country_code/country_iso2 is additionally checked against a 2-letter
    alpha pattern. Returns the ordered list of the first failing key(s), so
    the caller can log exactly which subfield would trigger BigCommerce's
    422, given no network calls, just the dict and the required-key table.
    """
    required_keys = required_keys or REQUIRED_KEYS
    address = address or {}
    missing = []
    for key in required_keys:
        value = _first_present(address, key)
        if value is None:
            missing.append(key)
            continue
        if key == "country_code" and not COUNTRY_CODE_RE.match(value):
            missing.append(key)
    return missing


def candidate_orders():
    """Page through orders at the configured status_ids (default Incomplete, Awaiting Fulfillment)."""
    page = 1
    while True:
        found_any = False
        for status_id in ORDER_STATUS_IDS:
            orders = bc_get(API_BASE_V2, "/orders", {"status_id": status_id, "page": page, "limit": 50})
            for order in orders:
                found_any = True
                yield order
        if not found_any:
            return
        page += 1


def order_shipping_addresses(order_id):
    return bc_get(API_BASE_V2, f"/orders/{order_id}/shippingaddresses")


def normalize_country_code(order_id, address_id, address):
    """Only writes when the correct country_code can be deterministically
    derived from a validated country name. Everything else stays flagged."""
    country_name = (address.get("country") or "").strip().lower()
    derived = KNOWN_COUNTRY_MAP.get(country_name)
    if not derived:
        return None

    before = {"country_code": address.get("country_iso2") or address.get("country_code")}
    after = {"country_code": derived}
    if DRY_RUN:
        return {"order_id": order_id, "dry_run": True, "before": before, "after": after}

    bc_put(API_BASE_V2, f"/orders/{order_id}/shippingaddresses/{address_id}", after)
    return {"order_id": order_id, "dry_run": False, "before": before, "after": after}


def run():
    flagged = 0
    clean = 0

    for order in candidate_orders():
        order_id = order["id"]
        addresses = order_shipping_addresses(order_id)

        for address in addresses or []:
            missing = find_missing_address_fields(address)
            if not missing:
                clean += 1
                continue

            flagged += 1
            log.warning(
                "order_id=%s address_id=%s missing_or_invalid_fields=%s address_snapshot=%s",
                order_id, address.get("id"), missing,
                {k: address.get(k) for k in ("first_name", "last_name", "street_1", "city",
                                              "state", "zip", "country", "country_iso2", "phone")},
            )

            if "country_code" in missing:
                result = normalize_country_code(order_id, address.get("id"), address)
                if result:
                    log.info("country_code normalization: %s", result)

    log.info("Done. %d address(es) flagged, %d address(es) already complete.", flagged, clean)


if __name__ == "__main__":
    run()
find-incomplete-fulfillment-addresses.js
/**
 * Find BigCommerce orders and checkouts whose fulfillment address looks
 * complete but is missing a subfield that trips 422 "A fulfillment address
 * for this order is incomplete" at order-creation or checkout-complete time.
 *
 * POST /v3/checkouts/{checkoutId}/consignments only strictly requires email
 * and country_code on the address plus lineItems, so a consignment can be
 * created successfully with a partial address. POST /v3/orders and checkout
 * complete validate a fuller set of subfields: first_name, last_name,
 * address1, city, state_or_province_code, postal_code, country_code, phone.
 * The missing key, commonly state_or_province_code, postal_code, or phone,
 * or an invalid country_code/country_iso2, is easy to miss because the
 * address object itself is present. This job lists candidate orders
 * (status_id 0 or 11), fetches each stored shipping address, and reports the
 * exact missing or invalid field per order id. It never invents a value; a
 * missing subfield is customer data this script cannot safely guess. Only a
 * narrow, deterministic normalization (a known-good country name to its
 * country_code) is ever written, gated by DRY_RUN.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/422-fulfillment-address-incomplete/
 */
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_V2 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v2`;
const API_BASE_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const ORDER_STATUS_IDS = (process.env.ORDER_STATUS_IDS || "0,11").split(",").map((s) => s.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

const REQUIRED_KEYS = [
  "first_name", "last_name", "address1", "city",
  "state_or_province_code", "postal_code", "country_code", "phone",
];

const ALIASES = {
  address1: ["address1", "street_1"],
  postal_code: ["postal_code", "zip"],
  country_code: ["country_code", "country_iso2"],
  state_or_province_code: ["state_or_province_code", "state_or_province", "state"],
};

const COUNTRY_CODE_RE = /^[A-Za-z]{2}$/;

// Narrow, deterministic country-name to country_code table. Extend only with
// values you have already validated; anything not in here stays flagged.
const KNOWN_COUNTRY_MAP = {
  "united states": "US",
  "united states of america": "US",
  canada: "CA",
  "united kingdom": "GB",
  australia: "AU",
};

function firstPresent(address, key) {
  for (const alias of ALIASES[key] || [key]) {
    const value = address[alias];
    if (value !== null && value !== undefined && String(value).trim() !== "") {
      return String(value).trim();
    }
  }
  return null;
}

/**
 * Pure decision. No network, no side effects.
 *
 * For each key in requiredKeys (accepting known aliases), check that the
 * address has a non-empty value under that key or one of its aliases.
 * countryCode/countryIso2 is additionally checked against a 2-letter alpha
 * pattern. Returns the ordered list of the first failing key(s), so the
 * caller can log exactly which subfield would trigger BigCommerce's 422,
 * given no network calls, just the object and the required-key table.
 */
export function findMissingAddressFields(address, requiredKeys = REQUIRED_KEYS) {
  address = address || {};
  const missing = [];
  for (const key of requiredKeys) {
    const value = firstPresent(address, key);
    if (value === null) {
      missing.push(key);
      continue;
    }
    if (key === "country_code" && !COUNTRY_CODE_RE.test(value)) {
      missing.push(key);
    }
  }
  return missing;
}

async function bcGet(base, path, params = {}) {
  const url = new URL(`${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) : [];
}

async function bcPut(base, path, body) {
  const res = await fetch(`${base}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* candidateOrders() {
  let page = 1;
  while (true) {
    let foundAny = false;
    for (const statusId of ORDER_STATUS_IDS) {
      const orders = await bcGet(API_BASE_V2, "/orders", { status_id: statusId, page, limit: 50 });
      for (const order of orders) {
        foundAny = true;
        yield order;
      }
    }
    if (!foundAny) return;
    page += 1;
  }
}

async function orderShippingAddresses(orderId) {
  return bcGet(API_BASE_V2, `/orders/${orderId}/shippingaddresses`);
}

async function normalizeCountryCode(orderId, addressId, address) {
  const countryName = (address.country || "").trim().toLowerCase();
  const derived = KNOWN_COUNTRY_MAP[countryName];
  if (!derived) return null;

  const before = { country_code: address.country_iso2 || address.country_code };
  const after = { country_code: derived };
  if (DRY_RUN) return { orderId, dryRun: true, before, after };

  await bcPut(API_BASE_V2, `/orders/${orderId}/shippingaddresses/${addressId}`, after);
  return { orderId, dryRun: false, before, after };
}

export async function run() {
  let flagged = 0;
  let clean = 0;

  for await (const order of candidateOrders()) {
    const orderId = order.id;
    const addresses = await orderShippingAddresses(orderId);

    for (const address of addresses || []) {
      const missing = findMissingAddressFields(address);
      if (!missing.length) {
        clean += 1;
        continue;
      }

      flagged += 1;
      console.warn(
        `order_id=${orderId} address_id=${address.id} missing_or_invalid_fields=${JSON.stringify(missing)} ` +
        `address_snapshot=${JSON.stringify({
          first_name: address.first_name, last_name: address.last_name, street_1: address.street_1,
          city: address.city, state: address.state, zip: address.zip, country: address.country,
          country_iso2: address.country_iso2, phone: address.phone,
        })}`
      );

      if (missing.includes("country_code")) {
        const result = await normalizeCountryCode(orderId, address.id, address);
        if (result) console.log("country_code normalization:", result);
      }
    }
  }

  console.log(`Done. ${flagged} address(es) flagged, ${clean} address(es) already complete.`);
}

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 is the difference between a bare "address incomplete" and a report that names the exact field. Because find_missing_address_fields takes only a plain dict and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_fulfillment_address_completeness.py
from find_incomplete_fulfillment_addresses import find_missing_address_fields


def complete_address(**overrides):
    address = {
        "first_name": "Jane",
        "last_name": "Doe",
        "address1": "123 Main St",
        "city": "Austin",
        "state_or_province_code": "TX",
        "postal_code": "78701",
        "country_code": "US",
        "phone": "5125550100",
    }
    address.update(overrides)
    return address


def test_no_missing_fields_on_a_fully_complete_address():
    assert find_missing_address_fields(complete_address()) == []


def test_reports_missing_state_or_province_code():
    address = complete_address(state_or_province_code=None)
    assert find_missing_address_fields(address) == ["state_or_province_code"]


def test_reports_missing_postal_code_when_empty_string():
    address = complete_address(postal_code="")
    assert find_missing_address_fields(address) == ["postal_code"]


def test_reports_missing_phone_when_key_absent_entirely():
    address = complete_address()
    del address["phone"]
    assert find_missing_address_fields(address) == ["phone"]


def test_accepts_zip_and_street_1_and_country_iso2_aliases():
    address = {
        "first_name": "Jane",
        "last_name": "Doe",
        "street_1": "123 Main St",
        "city": "Austin",
        "state": "TX",
        "zip": "78701",
        "country_iso2": "US",
        "phone": "5125550100",
    }
    assert find_missing_address_fields(address) == []


def test_reports_invalid_country_code_that_is_not_two_letters():
    address = complete_address(country_code="USA")
    assert find_missing_address_fields(address) == ["country_code"]


def test_reports_multiple_missing_fields_in_order():
    address = complete_address(postal_code="", phone="")
    assert find_missing_address_fields(address) == ["postal_code", "phone"]


def test_empty_address_reports_every_required_field():
    assert find_missing_address_fields({}) == [
        "first_name", "last_name", "address1", "city",
        "state_or_province_code", "postal_code", "country_code", "phone",
    ]
find-incomplete-fulfillment-addresses.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findMissingAddressFields } from "./find-incomplete-fulfillment-addresses.js";

const completeAddress = (overrides = {}) => ({
  first_name: "Jane",
  last_name: "Doe",
  address1: "123 Main St",
  city: "Austin",
  state_or_province_code: "TX",
  postal_code: "78701",
  country_code: "US",
  phone: "5125550100",
  ...overrides,
});

test("no missing fields on a fully complete address", () => {
  assert.deepEqual(findMissingAddressFields(completeAddress()), []);
});

test("reports missing state_or_province_code", () => {
  const address = completeAddress({ state_or_province_code: null });
  assert.deepEqual(findMissingAddressFields(address), ["state_or_province_code"]);
});

test("reports missing postal_code when empty string", () => {
  const address = completeAddress({ postal_code: "" });
  assert.deepEqual(findMissingAddressFields(address), ["postal_code"]);
});

test("reports missing phone when key absent entirely", () => {
  const address = completeAddress();
  delete address.phone;
  assert.deepEqual(findMissingAddressFields(address), ["phone"]);
});

test("accepts zip, street_1, and country_iso2 aliases", () => {
  const address = {
    first_name: "Jane",
    last_name: "Doe",
    street_1: "123 Main St",
    city: "Austin",
    state: "TX",
    zip: "78701",
    country_iso2: "US",
    phone: "5125550100",
  };
  assert.deepEqual(findMissingAddressFields(address), []);
});

test("reports invalid country_code that is not two letters", () => {
  const address = completeAddress({ country_code: "USA" });
  assert.deepEqual(findMissingAddressFields(address), ["country_code"]);
});

test("reports multiple missing fields in order", () => {
  const address = completeAddress({ postal_code: "", phone: "" });
  assert.deepEqual(findMissingAddressFields(address), ["postal_code", "phone"]);
});

test("empty address reports every required field", () => {
  assert.deepEqual(findMissingAddressFields({}), [
    "first_name", "last_name", "address1", "city",
    "state_or_province_code", "postal_code", "country_code", "phone",
  ]);
});

Case studies

Server-to-server checkout

The integration that assembled addresses from two sources

A headless storefront built its checkout consignment from a customer profile stored in an external CRM, merged with a manual override for the shipping address. The consignment call always returned 201, because it only needed email and country_code. Weeks later, a batch of orders started failing at POST /v3/orders with the generic 422, and no one could tell which orders or which field without opening each one in the admin.

Running the check against every stored shipping address showed the CRM merge was silently dropping phone whenever the customer profile had it under a different key. The report named the exact orders and the exact missing field, and the CRM mapping got fixed at the source instead of patched order by order.

Free-text state field

The store that collected state as a full name, not a code

A custom checkout form collected the shipping state as free text, "Texas" instead of "TX", and passed that straight through as state_or_province on the consignment. It looked complete in every admin view. Order placement kept failing with the fulfillment-address-incomplete 422 because BigCommerce wanted state_or_province_code, the coded form, not the free-text name.

The report flagged every affected order with state_or_province_code as the missing key, which made the root cause obvious immediately: the checkout form needed to submit the state code, not the label. No address was auto-guessed; the fix was upstream, in the form.

What good looks like

After this runs before order placement, "a fulfillment address for this order is incomplete" stops being a mystery. Every flagged order or checkout comes with the exact missing or invalid field name and a snapshot of the address, so customer service or an engineer can act on it directly. Nothing gets a synthetic postal code, phone number, or state code. The only writes that ever happen are narrow, deterministic normalizations you explicitly opted into, and only after a dry run confirms the diff.

FAQ

Why does BigCommerce say the fulfillment address is incomplete when I filled in the address fields?

Creating a consignment with POST /v3/checkouts/{checkoutId}/consignments only strictly requires the address object to carry email and country_code plus lineItems. That call succeeds even with a partial address, so the address looks filled in. Placing the order with POST /v3/orders or completing checkout requires a fuller set of subfields, first_name, last_name, address1, city, state_or_province_code, postal_code, country_code, and phone. The 422 only fires at that later step, and the missing key, commonly state_or_province_code, postal_code, or phone, is not obvious from a payload that already has an address object present.

Which address field is usually the culprit?

Most often it is state_or_province_code, postal_code, or phone being empty string, null, or missing entirely, or an invalid or mismatched country_code and country_iso2 pairing. Because the top-level address object is present, integrators assume it is complete and the specific missing leaf key never gets logged.

Should a script auto-fix the missing address field?

No, not by inventing values. A missing postal code, phone number, or state code is customer data that should not be guessed. The safe pattern is to detect and report the exact missing or invalid field per order or checkout id for manual or customer service follow-up. Only a narrow, deterministic normalization, such as mapping a validated country name to its country_code or a state name to its state_or_province_code, is safe to write, and only behind a DRY_RUN guard.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: 422 error, a fulfillment address for this order is incomplete, on POST v3/orders. support.bigcommerce.com 422 fulfillment address incomplete on POST v3/orders
  2. BigCommerce Support: 422, a shipping address for this order is incomplete. support.bigcommerce.com 422 shipping address incomplete
  3. BigCommerce Support: shipping address incomplete error using the server-to-server Checkout API. support.bigcommerce.com shipping address incomplete via server-to-server checkout

On the solution:

  1. BigCommerce Developer Center: checkout consignments. developer.bigcommerce.com checkout consignments
  2. BigCommerce Docs: create checkout consignment (storefront API reference). docs.bigcommerce.com create checkout consignment
  3. BigCommerce Developer Center: order shipping addresses. developer.bigcommerce.com order shipping addresses

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 name the field that was tripping you up?

If this saved you a support thread or a pile of guesswork on a bare 422, 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