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.
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.
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:
- An integrator builds the consignment payload from whatever address data is available early, gets a 201, and never revisits the address before calling
POST /v3/orderslater with the same, still-partial address. state_or_province_codeis left out because the integration only collected a free-textstate_or_provinceor no state at all, and BigCommerce's stricter check at order time wants the coded form.postal_codeorphoneis an empty string rather than omitted entirely. An empty string still reads as "field present" in a shallow completeness check, but BigCommerce treats it as missing.country_codeorcountry_iso2is present but does not resolve to a real ISO-3166 alpha-2 country, for example a stale or misspelled code from an older integration or a manual entry.- Server-to-server integrations that assemble the address from multiple sources (a CRM, a stored profile, a manual override) where one source silently drops a field and nothing downstream re-validates the merged result before the order-creation call.
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.
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.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it 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.
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
// 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
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.
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()
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();
}
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.
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")]
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);
}
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.
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
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;
}
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.
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}
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 };
}
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.
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 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 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.
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",
]
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
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.
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.
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:
- 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
- BigCommerce Support: 422, a shipping address for this order is incomplete. support.bigcommerce.com 422 shipping address incomplete
- 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:
- BigCommerce Developer Center: checkout consignments. developer.bigcommerce.com checkout consignments
- BigCommerce Docs: create checkout consignment (storefront API reference). docs.bigcommerce.com create checkout consignment
- 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.
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