Diagnostic Checkout / Carts
BigCommerce line item option valueId type is inconsistent across product option types
A script reads checkout.cart.lineItems, grabs each option's valueId, and forwards it straight into the order product_options array. For a dropdown it works. For a text field it sends null. For some carts the very same numeric id shows up as a string. The v2 Orders API rejects the mismatched shapes with a single vague error: the options of one or more products are invalid. Here is why valueId has three different shapes depending on the option type, and a normalizer that resolves the real catalog id before it ever reaches the order.
BigCommerce product options split into two families. Choice-based types (dropdown, radio, swatch, checkbox, rectangles/product list) resolve to a catalog option_value record with a numeric id. Free-input types (text, multi_line_text, numbers_only_text, date, file) have no option_values array at all. The Checkout SDK's LineItemOption.valueId reflects that split literally: numeric for choice options, null for free-input options, and across SDK/API versions that numeric id is sometimes serialized as a string. A script that forwards option.valueId straight into the POST /v2/orders product_options array (which expects {id, value}) breaks on both edges: null valueIds get sent as null or omitted, and string-typed ids fail strict type validation, producing "The options of one or more products are invalid." Do not guess a numeric id when one is missing. Instead, cross-reference GET /v3/catalog/products/{id}/options (or /modifiers) for the authoritative option_values[].id list, and run the normalizer below before you ever build the order payload. Full code, tests, and a dry run guard are below.
The problem in plain words
A BigCommerce product option is not one thing. Under the hood it is one of two very different shapes. A dropdown, radio button set, swatch, checkbox, or rectangles/product-list option is choice-based: BigCommerce stores a fixed list of option_values, each with its own numeric id, and the shopper's selection resolves to one of those ids. A text field, multi-line text field, numbers-only field, date picker, or file upload is free-input: there is no option_values array at all, because the shopper typed or uploaded something that was never a fixed choice.
The Checkout SDK's LineItemOption, which mirrors what the storefront checkout endpoint returns under cart.lineItems.*.options, exposes this split honestly through its valueId field. For a choice-based option, valueId is the numeric catalog id of the chosen option value. For a free-input option, valueId is null, because there was never a catalog id to point to. Across different SDK and API versions, that same numeric id also sometimes comes back serialized as a string instead of a number.
A script that reads checkout.cart.lineItems and copies option.valueId straight into the product_options array of a POST /v2/orders call sees all three shapes and treats them as one. It sends null where the order API needed literal text, and it sends a stringified id where the order API's strict validation wanted an integer. Both cases collapse into the same unhelpful response: "The options of one or more products are invalid." bigcommerce/checkout-sdk-js issue #474 documents the same root complaint from the other direction: the exposed value_id is not even guaranteed to be in the id-space the order API expects for choice options, which compounds the null/string inconsistency.
Why it happens
The inconsistency is not a bug in one endpoint, it is two different data models colliding at the point a script assumes they are the same field:
- Choice-based option types (dropdown, radio_buttons, rectangles, swatch, product_list, checkbox) are backed by a real
option_valuescatalog record, each with its own numericid. That id is whatvalueIdcarries. - Free-input option types (text, multi_line_text, numbers_only_text, date, file) have no
option_valuesarray at all, because the shopper supplied arbitrary text or a file rather than picking from a fixed list, sovalueIdis alwaysnullfor these. - Across BigCommerce Checkout SDK and API versions, the numeric id for choice-based options is sometimes serialized as a JSON number and sometimes as a string, so
typeofalone is not a reliable signal of validity. - The v2 Orders API's
product_optionsarray expects{id, value}, wherevaluemeans two different things depending on option type: the numeric catalog option-value id for choice types, or the raw literal string for free-input types. A script that does not branch onoption.typecannot tell which meaning applies. - bigcommerce/checkout-sdk-js issue #474 independently confirms the exposed
value_idis not guaranteed to be in the same id-space the order API expects for choice options, so even a numeric-looking valueId is not safe to trust without cross-referencing the catalog.
See the citations at the end for the exact issue thread and BigCommerce support case this is drawn from.
A line item option's valueId is not proof of anything by itself. The product's own option_values catalog is. So the safe pattern is not "forward whatever valueId the cart gives you." It is "look at option.type first: free-input types never need a numeric id at all, and choice-based types need their id resolved against GET /v3/catalog/products/{id}/options or /modifiers, matching by id first and by label as a fallback, never guessed."
The fix, as a flow
We do not touch checkout or the storefront cart. We add a normalizer that runs right before the order payload is built, branches on the option's type, and only ever emits an {id, value} pair the v2 Orders API will actually accept.
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 Products (read-only) scope to read catalog options and modifiers, and Orders (modify) scope if this feeds an order-creation flow. 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 DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" // start safe, change to false to write
Talk to the V3 Catalog and V2 Orders REST APIs
Catalog options and modifiers live under https://api.bigcommerce.com/stores/{store_hash}/v3/. Order creation lives under the v2/ base. Both share the same X-Auth-Token header. A small helper handles GET and raises on a non-2xx response, and unwraps the v3 {data, meta} envelope.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
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_v3(path, params=None):
r = requests.get(f"{API_BASE_V3}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
body = r.json() if r.text else {}
return body.get("data", [])
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
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 bcGetV3(path, params = {}) {
const url = new URL(`${API_BASE_V3}${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();
const body = text ? JSON.parse(text) : {};
return body.data || [];
}
Fetch the authoritative option values for a product
Before you can trust any valueId, you need the product's own list of choices. Call GET /v3/catalog/products/{product_id}/options and GET /v3/catalog/products/{product_id}/modifiers, and for each choice-based option collect its option_values[] as {id, label} pairs. Free-input options in this response will have no option_values at all, which itself confirms the type.
def product_option_values(product_id):
"""Map every choice-based option's id to its list of {id, label} option_values."""
by_option_id = {}
for endpoint in ("options", "modifiers"):
for option in bc_get_v3(f"/catalog/products/{product_id}/{endpoint}"):
values = option.get("option_values") or []
by_option_id[option["id"]] = [
{"id": v["id"], "label": v.get("label", "")} for v in values
]
return by_option_id
async function productOptionValues(productId) {
// Map every choice-based option's id to its list of {id, label} option_values.
const byOptionId = {};
for (const endpoint of ["options", "modifiers"]) {
const options = await bcGetV3(`/catalog/products/${productId}/${endpoint}`);
for (const option of options) {
const values = option.option_values || [];
byOptionId[option.id] = values.map((v) => ({ id: v.id, label: v.label || "" }));
}
}
return byOptionId;
}
Normalize, with one pure function
Keep the decision in its own function that takes the raw line item option and the product's catalog option values, and returns the {id, value} pair the order API actually wants. Free-input types, and any option with a null valueId, pass the literal text straight through since there was never a numeric id to resolve. Choice-based types coerce the valueId to a number, confirm it exists in the catalog list, and fall back to a label match on option.value if the numeric id does not resolve. If nothing matches, the function flags the option rather than guessing.
FREE_INPUT_TYPES = {"text", "multi_line_text", "numbers_only_text", "date", "file"}
class OptionValueUnresolvedError(ValueError):
pass
def normalize_line_item_option_value(option, catalog_option_values):
"""option: {type, value, valueId, optionId, nameId}
catalog_option_values: list of {id, label} for this option's choices.
Returns {"id": int, "value": str|int}.
"""
is_free_input = option.get("type") in FREE_INPUT_TYPES
value_id = option.get("valueId")
if is_free_input or value_id is None:
return {
"id": option.get("optionId") if option.get("optionId") is not None else option.get("nameId"),
"value": str(option.get("value")),
}
try:
numeric_id = int(value_id)
except (TypeError, ValueError):
numeric_id = None
if numeric_id is not None:
for entry in catalog_option_values:
if entry["id"] == numeric_id:
return {"id": numeric_id, "value": option.get("value")}
label = option.get("value")
for entry in catalog_option_values:
if entry.get("label") == label:
return {"id": entry["id"], "value": option.get("value")}
raise OptionValueUnresolvedError(
f"Could not resolve option value id for valueId={value_id!r} value={label!r}"
)
const FREE_INPUT_TYPES = new Set(["text", "multi_line_text", "numbers_only_text", "date", "file"]);
export class OptionValueUnresolvedError extends Error {}
export function normalizeLineItemOptionValue(option, catalogOptionValues) {
const isFreeInput = FREE_INPUT_TYPES.has(option.type);
const valueId = option.valueId;
if (isFreeInput || valueId === null || valueId === undefined) {
return {
id: option.optionId ?? option.nameId,
value: String(option.value),
};
}
const numericId = Number(valueId);
if (!Number.isNaN(numericId)) {
const byId = catalogOptionValues.find((entry) => entry.id === numericId);
if (byId) return { id: numericId, value: option.value };
}
const byLabel = catalogOptionValues.find((entry) => entry.label === option.value);
if (byLabel) return { id: byLabel.id, value: option.value };
throw new OptionValueUnresolvedError(
`Could not resolve option value id for valueId=${JSON.stringify(valueId)} value=${JSON.stringify(option.value)}`
);
}
Reconcile existing carts before you touch order creation
Do not backfill orders from mismatched data by guessing a numeric id. Instead, walk open and abandoned carts with GET /v3/carts/{cart_id}?include=line_items.physical_items.options,line_items.digital_items.options, cross-reference each option against the product's option_values, and log any mismatch: a choice-based type with a null, empty, or non-numeric valueId, or a free-input type where the script previously treated literal text as a numeric id.
CHOICE_TYPES = {"dropdown", "radio_buttons", "rectangles", "swatch", "product_list", "checkbox"}
def find_mismatches(cart_id, line_items, option_types_by_product):
mismatches = []
for item in line_items:
product_id = item["product_id"]
option_types = option_types_by_product.get(product_id, {})
for option in item.get("options", []):
option_type = option_types.get(option.get("nameId"), option.get("type"))
value_id = option.get("valueId")
is_choice = option_type in CHOICE_TYPES
looks_numeric = isinstance(value_id, int) or (
isinstance(value_id, str) and value_id.isdigit()
)
if is_choice and not looks_numeric:
mismatches.append({
"cart_id": cart_id,
"product_id": product_id,
"option_id": option.get("nameId") or option.get("optionId"),
"option_type": option_type,
"raw_value_id_typeof": type(value_id).__name__,
})
return mismatches
const CHOICE_TYPES = new Set(["dropdown", "radio_buttons", "rectangles", "swatch", "product_list", "checkbox"]);
export function findMismatches(cartId, lineItems, optionTypesByProduct) {
const mismatches = [];
for (const item of lineItems) {
const productId = item.product_id;
const optionTypes = optionTypesByProduct[productId] || {};
for (const option of item.options || []) {
const optionType = optionTypes[option.nameId] || option.type;
const valueId = option.valueId;
const isChoice = CHOICE_TYPES.has(optionType);
const looksNumeric =
typeof valueId === "number" || (typeof valueId === "string" && /^\d+$/.test(valueId));
if (isChoice && !looksNumeric) {
mismatches.push({
cart_id: cartId,
product_id: productId,
option_id: option.nameId || option.optionId,
option_type: optionType,
raw_value_id_typeof: valueId === null ? "null" : typeof valueId,
});
}
}
}
return mismatches;
}
Wire it together with a dry run guard
The loop ties every piece together: pull candidate carts, resolve each product's catalog option values once and cache them, run the normalizer per option, and log every {cart_id, option_id, before, after} diff. Leave DRY_RUN on so nothing is ever written to POST /v2/orders until you have read the diff log and agreed with it.
Never guess a numeric option-value id for a mismatched or missing valueId. Flag the cart, product, and option combination for a human, and only build an order payload from an id the normalizer resolved against the product's real option_values catalog. Always start with DRY_RUN=true.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never writes an order from an option value id it could not confirm against the product's own catalog.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Normalize BigCommerce line item option valueId before building an order payload.
BigCommerce product options split into two families. Choice-based types
(dropdown, radio_buttons, rectangles, swatch, product_list, checkbox) resolve to
a catalog option_value record with a numeric id. Free-input types (text,
multi_line_text, numbers_only_text, date, file) have no option_values array at
all. The Checkout SDK's LineItemOption.valueId reflects that split literally:
numeric for choice options, null for free-input options, and across SDK/API
versions that numeric id is sometimes serialized as a string. A script that
forwards option.valueId straight into the v2 POST /v2/orders product_options
array (which expects {id, value}) breaks: null valueIds get sent as null or
omitted, and string-typed ids fail strict type validation, producing
"The options of one or more products are invalid." This script cross-references
each product's real option_values catalog via GET /v3/catalog/products/{id}/options
and /modifiers, walks open and abandoned carts, and reports every mismatch. It
never guesses a numeric id; anything unresolved is flagged, never auto-written.
Guide: https://www.allanninal.dev/bigcommerce/line-item-option-valueid-type-inconsistent/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("normalize_line_item_options")
STORE_HASH = os.environ.get("BIGCOMMERCE_STORE_HASH", "example_hash")
ACCESS_TOKEN = os.environ.get("BIGCOMMERCE_ACCESS_TOKEN", "bc_dummy")
API_BASE_V3 = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
FREE_INPUT_TYPES = {"text", "multi_line_text", "numbers_only_text", "date", "file"}
CHOICE_TYPES = {"dropdown", "radio_buttons", "rectangles", "swatch", "product_list", "checkbox"}
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
class OptionValueUnresolvedError(ValueError):
pass
def bc_get_v3(path, params=None):
r = requests.get(f"{API_BASE_V3}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
body = r.json() if r.text else {}
return body.get("data", [])
def product_option_values(product_id):
"""Map every choice-based option's id to its list of {id, label} option_values."""
by_option_id = {}
for endpoint in ("options", "modifiers"):
for option in bc_get_v3(f"/catalog/products/{product_id}/{endpoint}"):
values = option.get("option_values") or []
by_option_id[option["id"]] = [
{"id": v["id"], "label": v.get("label", "")} for v in values
]
return by_option_id
def normalize_line_item_option_value(option, catalog_option_values):
"""Pure decision. No network, no side effects.
option: {type, value, valueId, optionId, nameId}
catalog_option_values: list of {id, label} for this option's choices.
If option.type is free-input, or valueId is None, return the literal text
passthrough: {id: option.optionId or option.nameId, value: str(option.value)}.
Otherwise coerce valueId to a number and confirm it exists in
catalog_option_values. If that fails, fall back to a label match on
option.value. If nothing matches, raise OptionValueUnresolvedError rather
than silently sending a bad id.
"""
is_free_input = option.get("type") in FREE_INPUT_TYPES
value_id = option.get("valueId")
if is_free_input or value_id is None:
return {
"id": option.get("optionId") if option.get("optionId") is not None else option.get("nameId"),
"value": str(option.get("value")),
}
try:
numeric_id = int(value_id)
except (TypeError, ValueError):
numeric_id = None
if numeric_id is not None:
for entry in catalog_option_values:
if entry["id"] == numeric_id:
return {"id": numeric_id, "value": option.get("value")}
label = option.get("value")
for entry in catalog_option_values:
if entry.get("label") == label:
return {"id": entry["id"], "value": option.get("value")}
raise OptionValueUnresolvedError(
f"Could not resolve option value id for valueId={value_id!r} value={label!r}"
)
def find_mismatches(cart_id, line_items, option_types_by_product):
"""Flag choice-based options whose valueId is null, empty, or non-numeric."""
mismatches = []
for item in line_items:
product_id = item["product_id"]
option_types = option_types_by_product.get(product_id, {})
for option in item.get("options", []):
option_type = option_types.get(option.get("nameId"), option.get("type"))
value_id = option.get("valueId")
is_choice = option_type in CHOICE_TYPES
looks_numeric = isinstance(value_id, int) or (
isinstance(value_id, str) and value_id.isdigit()
)
if is_choice and not looks_numeric:
mismatches.append({
"cart_id": cart_id,
"product_id": product_id,
"option_id": option.get("nameId") or option.get("optionId"),
"option_type": option_type,
"raw_value_id_typeof": type(value_id).__name__,
})
return mismatches
def candidate_carts():
"""Page through open and abandoned carts."""
page = 1
while True:
carts = bc_get_v3(
"/carts",
{"page": page, "limit": 50, "include": "line_items.physical_items.options,line_items.digital_items.options"},
)
if not carts:
return
for cart in carts:
yield cart
page += 1
def run():
reported = 0
resolved = 0
unresolved = 0
option_values_cache = {}
for cart in candidate_carts():
cart_id = cart["id"]
line_items = (cart.get("line_items", {}).get("physical_items", []) or []) + (
cart.get("line_items", {}).get("digital_items", []) or []
)
option_types_by_product = {}
for item in line_items:
product_id = item["product_id"]
if product_id not in option_values_cache:
option_values_cache[product_id] = product_option_values(product_id)
option_types_by_product[product_id] = {
oid: [] for oid in option_values_cache[product_id]
}
mismatches = find_mismatches(cart_id, line_items, option_types_by_product)
for mismatch in mismatches:
log.warning("Mismatch found: %s", mismatch)
reported += 1
for item in line_items:
product_id = item["product_id"]
catalog = option_values_cache.get(product_id, {})
for option in item.get("options", []):
option_id = option.get("nameId") or option.get("optionId")
values_for_option = catalog.get(option_id, [])
try:
normalized = normalize_line_item_option_value(option, values_for_option)
log.info(
"cart_id=%s option_id=%s before=%s after=%s (%s)",
cart_id, option_id, option.get("valueId"), normalized,
"dry run" if DRY_RUN else "resolved",
)
resolved += 1
except OptionValueUnresolvedError as exc:
log.warning("cart_id=%s option_id=%s unresolved: %s", cart_id, option_id, exc)
unresolved += 1
log.info(
"Done. %d mismatch(es) reported, %d option(s) resolved, %d option(s) unresolved.",
reported, resolved, unresolved,
)
if __name__ == "__main__":
run()
/**
* Normalize BigCommerce line item option valueId before building an order payload.
*
* BigCommerce product options split into two families. Choice-based types
* (dropdown, radio_buttons, rectangles, swatch, product_list, checkbox) resolve to
* a catalog option_value record with a numeric id. Free-input types (text,
* multi_line_text, numbers_only_text, date, file) have no option_values array at
* all. The Checkout SDK's LineItemOption.valueId reflects that split literally:
* numeric for choice options, null for free-input options, and across SDK/API
* versions that numeric id is sometimes serialized as a string. A script that
* forwards option.valueId straight into the v2 POST /v2/orders product_options
* array (which expects {id, value}) breaks: null valueIds get sent as null or
* omitted, and string-typed ids fail strict type validation, producing
* "The options of one or more products are invalid." This script cross-references
* each product's real option_values catalog via GET /v3/catalog/products/{id}/options
* and /modifiers, walks open and abandoned carts, and reports every mismatch. It
* never guesses a numeric id; anything unresolved is flagged, never auto-written.
*
* Guide: https://www.allanninal.dev/bigcommerce/line-item-option-valueid-type-inconsistent/
*/
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_V3 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const FREE_INPUT_TYPES = new Set(["text", "multi_line_text", "numbers_only_text", "date", "file"]);
const CHOICE_TYPES = new Set(["dropdown", "radio_buttons", "rectangles", "swatch", "product_list", "checkbox"]);
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
export class OptionValueUnresolvedError extends Error {}
async function bcGetV3(path, params = {}) {
const url = new URL(`${API_BASE_V3}${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();
const body = text ? JSON.parse(text) : {};
return body.data || [];
}
async function productOptionValues(productId) {
// Map every choice-based option's id to its list of {id, label} option_values.
const byOptionId = {};
for (const endpoint of ["options", "modifiers"]) {
const options = await bcGetV3(`/catalog/products/${productId}/${endpoint}`);
for (const option of options) {
const values = option.option_values || [];
byOptionId[option.id] = values.map((v) => ({ id: v.id, label: v.label || "" }));
}
}
return byOptionId;
}
/**
* Pure decision. No network, no side effects.
*
* option: {type, value, valueId, optionId, nameId}
* catalogOptionValues: list of {id, label} for this option's choices.
*
* If option.type is free-input, or valueId is null/undefined, return the
* literal text passthrough: {id: option.optionId ?? option.nameId, value: String(option.value)}.
* Otherwise coerce valueId to a number and confirm it exists in
* catalogOptionValues. If that fails, fall back to a label match on
* option.value. If nothing matches, throw OptionValueUnresolvedError rather
* than silently sending a bad id.
*/
export function normalizeLineItemOptionValue(option, catalogOptionValues) {
const isFreeInput = FREE_INPUT_TYPES.has(option.type);
const valueId = option.valueId;
if (isFreeInput || valueId === null || valueId === undefined) {
return {
id: option.optionId ?? option.nameId,
value: String(option.value),
};
}
const numericId = Number(valueId);
if (!Number.isNaN(numericId)) {
const byId = catalogOptionValues.find((entry) => entry.id === numericId);
if (byId) return { id: numericId, value: option.value };
}
const byLabel = catalogOptionValues.find((entry) => entry.label === option.value);
if (byLabel) return { id: byLabel.id, value: option.value };
throw new OptionValueUnresolvedError(
`Could not resolve option value id for valueId=${JSON.stringify(valueId)} value=${JSON.stringify(option.value)}`
);
}
export function findMismatches(cartId, lineItems, optionTypesByProduct) {
// Flag choice-based options whose valueId is null, empty, or non-numeric.
const mismatches = [];
for (const item of lineItems) {
const productId = item.product_id;
const optionTypes = optionTypesByProduct[productId] || {};
for (const option of item.options || []) {
const optionType = optionTypes[option.nameId] || option.type;
const valueId = option.valueId;
const isChoice = CHOICE_TYPES.has(optionType);
const looksNumeric =
typeof valueId === "number" || (typeof valueId === "string" && /^\d+$/.test(valueId));
if (isChoice && !looksNumeric) {
mismatches.push({
cart_id: cartId,
product_id: productId,
option_id: option.nameId || option.optionId,
option_type: optionType,
raw_value_id_typeof: valueId === null ? "null" : typeof valueId,
});
}
}
}
return mismatches;
}
async function* candidateCarts() {
let page = 1;
while (true) {
const carts = await bcGetV3("/carts", {
page,
limit: 50,
include: "line_items.physical_items.options,line_items.digital_items.options",
});
if (!carts.length) return;
for (const cart of carts) yield cart;
page += 1;
}
}
export async function run() {
let reported = 0;
let resolved = 0;
let unresolved = 0;
const optionValuesCache = {};
for await (const cart of candidateCarts()) {
const cartId = cart.id;
const lineItems = [
...((cart.line_items && cart.line_items.physical_items) || []),
...((cart.line_items && cart.line_items.digital_items) || []),
];
const optionTypesByProduct = {};
for (const item of lineItems) {
const productId = item.product_id;
if (!optionValuesCache[productId]) {
optionValuesCache[productId] = await productOptionValues(productId);
}
optionTypesByProduct[productId] = Object.fromEntries(
Object.keys(optionValuesCache[productId]).map((oid) => [oid, []])
);
}
const mismatches = findMismatches(cartId, lineItems, optionTypesByProduct);
for (const mismatch of mismatches) {
console.warn("Mismatch found:", mismatch);
reported += 1;
}
for (const item of lineItems) {
const productId = item.product_id;
const catalog = optionValuesCache[productId] || {};
for (const option of item.options || []) {
const optionId = option.nameId || option.optionId;
const valuesForOption = catalog[optionId] || [];
try {
const normalized = normalizeLineItemOptionValue(option, valuesForOption);
console.log(
`cart_id=${cartId} option_id=${optionId} before=${JSON.stringify(option.valueId)} ` +
`after=${JSON.stringify(normalized)} (${DRY_RUN ? "dry run" : "resolved"})`
);
resolved += 1;
} catch (err) {
if (err instanceof OptionValueUnresolvedError) {
console.warn(`cart_id=${cartId} option_id=${optionId} unresolved: ${err.message}`);
unresolved += 1;
} else {
throw err;
}
}
}
}
}
console.log(
`Done. ${reported} mismatch(es) reported, ${resolved} option(s) resolved, ${unresolved} option(s) unresolved.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The normalizer is the part most worth testing, because it decides what id ends up in an order payload. Because normalize_line_item_option_value takes only plain values and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.
import pytest
from normalize_line_item_options import (
normalize_line_item_option_value,
OptionValueUnresolvedError,
)
CATALOG = [{"id": 42, "label": "Red"}, {"id": 43, "label": "Blue"}]
def test_free_input_type_passes_literal_text_through():
option = {"type": "text", "value": "Engrave: Happy Birthday", "valueId": None, "optionId": 9}
assert normalize_line_item_option_value(option, []) == {
"id": 9,
"value": "Engrave: Happy Birthday",
}
def test_null_valueid_passes_literal_text_through_even_for_choice_type():
option = {"type": "dropdown", "value": "Red", "valueId": None, "nameId": 5}
assert normalize_line_item_option_value(option, CATALOG) == {"id": 5, "value": "Red"}
def test_numeric_valueid_resolves_directly():
option = {"type": "dropdown", "value": "Red", "valueId": 42}
assert normalize_line_item_option_value(option, CATALOG) == {"id": 42, "value": "Red"}
def test_string_valueid_coerces_and_resolves():
option = {"type": "swatch", "value": "Blue", "valueId": "43"}
assert normalize_line_item_option_value(option, CATALOG) == {"id": 43, "value": "Blue"}
def test_stale_numeric_id_falls_back_to_label_match():
option = {"type": "dropdown", "value": "Red", "valueId": 999}
assert normalize_line_item_option_value(option, CATALOG) == {"id": 42, "value": "Red"}
def test_unresolved_id_and_label_raises():
option = {"type": "dropdown", "value": "Green", "valueId": "not-an-id"}
with pytest.raises(OptionValueUnresolvedError):
normalize_line_item_option_value(option, CATALOG)
import { test } from "node:test";
import assert from "node:assert/strict";
import {
normalizeLineItemOptionValue,
OptionValueUnresolvedError,
} from "./normalize-line-item-options.js";
const CATALOG = [{ id: 42, label: "Red" }, { id: 43, label: "Blue" }];
test("free-input type passes literal text through", () => {
const option = { type: "text", value: "Engrave: Happy Birthday", valueId: null, optionId: 9 };
assert.deepEqual(normalizeLineItemOptionValue(option, []), {
id: 9,
value: "Engrave: Happy Birthday",
});
});
test("null valueId passes literal text through even for choice type", () => {
const option = { type: "dropdown", value: "Red", valueId: null, nameId: 5 };
assert.deepEqual(normalizeLineItemOptionValue(option, CATALOG), { id: 5, value: "Red" });
});
test("numeric valueId resolves directly", () => {
const option = { type: "dropdown", value: "Red", valueId: 42 };
assert.deepEqual(normalizeLineItemOptionValue(option, CATALOG), { id: 42, value: "Red" });
});
test("string valueId coerces and resolves", () => {
const option = { type: "swatch", value: "Blue", valueId: "43" };
assert.deepEqual(normalizeLineItemOptionValue(option, CATALOG), { id: 43, value: "Blue" });
});
test("stale numeric id falls back to label match", () => {
const option = { type: "dropdown", value: "Red", valueId: 999 };
assert.deepEqual(normalizeLineItemOptionValue(option, CATALOG), { id: 42, value: "Red" });
});
test("unresolved id and label throws", () => {
const option = { type: "dropdown", value: "Green", valueId: "not-an-id" };
assert.throws(() => normalizeLineItemOptionValue(option, CATALOG), OptionValueUnresolvedError);
});
Case studies
The store that sent null into product_options for a personalization field
A store selling personalized jewelry had a free-input text option for an engraving message. A checkout automation copied every line item option's valueId straight into the v2 Orders API payload for a back-office reorder flow. For the metal-finish dropdown it worked fine. For the engraving text field it sent {"id": 9, "value": null}, because valueId is always null for text options, and the order rejected with the options of one or more products are invalid.
Switching to the normalizer fixed it immediately: free-input types now pass String(option.value), the actual engraving text, instead of the null valueId, and the choice-based finish option resolves through the catalog exactly as before.
The integration that failed only on one storefront theme
A multi-storefront merchant had one theme running an older Checkout SDK build. Every dropdown option value came through as a string, like "42" instead of 42, while every other theme's cart returned numbers. The order API's strict type validation rejected the string form outright, and the failure only ever showed up from that one theme, which made it look storefront-specific instead of type-specific.
The normalizer's Number(valueId) coercion step resolved it for every theme at once, since it treats a numeric string and a real number identically as long as the resulting id exists in the product's catalog option_values.
After the normalizer sits in front of order creation, valueId's three shapes stop mattering. Free-input options always contribute their literal text. Choice-based options always resolve against the product's real option_values catalog, whether the incoming id was a number, a numeric string, or missing entirely because it fell back to a label match. Anything that cannot be resolved is raised as OptionValueUnresolvedError and flagged for a human, never silently guessed into an order.
FAQ
Why is BigCommerce line item option.valueId sometimes a number, sometimes null, and sometimes a string?
BigCommerce product options fall into two families. Choice-based types like dropdown, radio, swatch, checkbox, and rectangles resolve to a catalog option_value record with a numeric id, so valueId carries that number. Free-input types like text, multi_line_text, numbers_only_text, date, and file have no option_values array at all, so valueId is null. Across SDK and API versions that numeric id is also sometimes serialized as a string, which is why a script that blindly forwards valueId sees three different shapes for what looks like one field.
Why does forwarding checkout.cart line item option.valueId straight into POST /v2/orders break order creation?
The v2 Orders API product_options array expects {id, value} where value is either the numeric catalog option-value id for choice-based options or the raw literal text for free-input options. A script that forwards option.valueId directly sends null or an omitted value for free-input options that actually need the literal text, and sends a string-typed id for choice options that fails strict type validation on the order endpoint, both of which surface as The options of one or more products are invalid.
Is it safe to guess the numeric option-value id when a captured cart is missing one?
No. Do not auto-write orders from a mismatched or missing valueId. Flag the cart, product, and option combination for a reconciliation report instead, and only resolve the id going forward by cross-referencing GET /v3/catalog/products/{id}/options or /modifiers for the authoritative option_values list, matching by id first and falling back to a label match on option.value.
Related field notes
Citations
On the problem:
- bigcommerce/checkout-sdk-js Issue #474: LineItemOption does not expose the ID needed for the POST order API call. github.com bigcommerce/checkout-sdk-js issue #474
- BigCommerce Support: creating an order using Orders API v2 with product variant options using custom text values. support.bigcommerce.com creating an order using Orders API v2 with custom text values
- BigCommerce Developer Center: Checkout Cart Items. developer.bigcommerce.com checkout cart items
On the solution:
- BigCommerce Help Center: Product Options (v3). support.bigcommerce.com product options (v3)
- BigCommerce Developer Center: Product Modifiers. developer.bigcommerce.com product modifiers
- BigCommerce Developer Center: Orders Overview. developer.bigcommerce.com orders overview
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 untangle a broken order payload?
If this saved you from guessing option-value ids or chasing a vague invalid-options error, 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