Diagnostic Catalog / Products
Product image upload rejects non fully qualified URLs
A bulk or scripted import creates the product fine, then the image call comes back with a 422 image_url is invalid error. BigCommerce fetches the image file itself, server-side, from the URL you send it, so a relative path, a root-relative path, or a bare filename gives its fetcher nothing to resolve against. The import job moves on, the product stays real, and it just sits there with zero images. Here is why that happens and a small script that finds every zero-image product and safely resolves the ones it can.
BigCommerce creates a product image by URL through POST /v3/catalog/products/{product_id}/images with a JSON body containing image_url, and its servers fetch that remote file themselves. Because the fetch happens server-side, image_url must be a fully qualified absolute URL with a scheme, http or https, and a host. Relative paths, protocol-relative URLs, and bare filenames have no host for BigCommerce to resolve, so the request is rejected with a 422 image_url is invalid error. This hits bulk or CSV imports the hardest, since the source system often stores relative or root-relative image paths, and the failure only takes down that one image row, not the product itself. Run a small Python or Node.js script that lists products with GET /v3/catalog/products?include=images, flags the ones with an empty images array, cross-references the import failure log for the original image_url, and resolves the fixable ones against a known source base URL before retrying the same POST. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce does not accept a raw image file upload on the by-URL create endpoint. Instead, you send it a URL, and BigCommerce's own servers go fetch that file from the internet on your behalf, then store the result as the product's image. That server-side fetch is the whole reason the URL you send has to be complete. BigCommerce has no browsing context, no current page, and no working directory to resolve a relative path against. It just gets a string, and if that string does not parse into a scheme plus a host, there is nothing to fetch.
This almost never shows up when someone is adding one image by hand in the admin, because the admin UI always has a real, absolute URL to hand over. It shows up constantly in bulk and scripted imports, migrations from another platform, a CSV that came out of a legacy system, or a script that read image paths straight out of a database that stored them relative to its own web root. Those source systems are used to serving images from their own domain, so a path like /images/shoe.jpg or images/shoe.jpg was perfectly valid there. Sent to BigCommerce as-is, it has no scheme and no host, and the API returns a 422 with a message like image_url is invalid.
The part that catches people off guard is what happens next. The product creation itself, POST /v3/catalog/products, already succeeded before the import script got to the image step. So the failed image call does not roll back the product. It fails on its own, the import job logs it and moves to the next row, and you are left with a completely real, live product that has zero images and nothing telling the storefront or the merchant that anything is missing, aside from the import log if anyone goes back and reads it.
Why it happens
A handful of specific patterns lead to this exact 422, and they all come down to the same root cause, a URL string with no scheme and no host for BigCommerce's fetcher to resolve:
- A source system's database or export stores image paths relative to its own web root, for example
/images/shoe.jpg, and the migration script copies that string straight intoimage_urlwithout ever prefixing it with the source domain. - A protocol-relative URL,
//cdn.example.com/shoe.jpg, which works fine inside a browser that already knows its own page's scheme, but has no scheme of its own when sent as a raw string to a server-side fetcher with no page context. - A bare filename with no path at all,
shoe.jpg, left over from a CSV column that only ever stored the filename because the rest of the path was assumed to be constant. - A CSV or scripted bulk import where the product row and the image row are two separate write operations, so the product record commits successfully even though its paired image call fails on the very same row.
Because the product record itself is unaffected, this defect does not show up as a failed product creation anywhere in the BigCommerce admin. It only surfaces as a product with an empty images array, and the only place that records the original bad image_url is the import job's own failure log. See the citations at the end for the exact support threads where merchants hit this same 422.
A zero-image product is not proof of this specific defect. It could also be a host that was unreachable, a file that was too large, or a merchant who genuinely never uploaded an image. The reliable signal is the import job's own failure log, because that is the only place the original image_url that was actually sent is recorded. So the pattern is not "guess a host for every relative path and retry." It is "list zero-image products, cross-reference each one against the failure log, confirm the recorded image_url really was non fully qualified, and only then decide whether it is fixable against a known source base URL or needs a human."
The fix, as a flow
We do not touch the live product creation flow. We add a job that finds every zero-image product, matches it against the import failure log to get the original image_url, decides per URL whether it can be safely resolved to an absolute URL, and either retries the image POST or routes the product to a human review queue.
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 reuse an existing app's credentials. Grant it Products (modify) scope so it can read the catalog and create product images. 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 SOURCE_BASE_URL="https://cdn.oldstore.example.com"
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 SOURCE_BASE_URL="https://cdn.oldstore.example.com"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V3 Catalog API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header and Accept: application/json. A small helper handles GET and POST and raises on a non-2xx response. We reuse it to list products with their images and to create the fixed image.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_post(path, body):
r = requests.post(f"{API_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 = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List zero-image products
Call GET /v3/catalog/products?include=images&limit=250&page={n}, paginated with meta.pagination.total_pages, and flag any product in data[] whose images array is empty. That flagged set is your starting candidate list, before we know yet whether the cause is this defect or something unrelated.
def zero_image_products():
page = 1
while True:
resp = bc_get("/catalog/products", {"include": "images", "limit": 250, "page": page})
products = resp.get("data") or []
if not products:
return
for product in products:
if not product.get("images"):
yield product
total_pages = resp.get("meta", {}).get("pagination", {}).get("total_pages", page)
if page >= total_pages:
return
page += 1
async function* zeroImageProducts() {
let page = 1;
while (true) {
const resp = await bcGet("/catalog/products", { include: "images", limit: 250, page });
const products = resp.data || [];
if (!products.length) return;
for (const product of products) {
if (!product.images || !product.images.length) yield product;
}
const totalPages = resp.meta?.pagination?.total_pages ?? page;
if (page >= totalPages) return;
page += 1;
}
}
Decide, with one pure function
Once you have the original image_url from the import failure log for a flagged product, is_fixable_image_url is the pure decision. It takes only the raw URL and an optional source base URL and returns one of four outcomes, no I/O, no network. An already-absolute URL needs no fix. A relative or root-relative path with a base URL to resolve against is fixable. The same path with no base URL needs a human. Any other scheme, ftp or data for example, is unsupported and also needs a human.
from urllib.parse import urlsplit, urljoin
def is_fixable_image_url(raw_url, source_base_url=None):
parts = urlsplit(raw_url or "")
if parts.scheme in ("http", "https") and parts.netloc:
return {"status": "already_valid", "resolved_url": raw_url}
if parts.scheme and parts.scheme not in ("http", "https"):
return {"status": "unsupported_scheme", "resolved_url": None}
base_parts = urlsplit(source_base_url or "")
base_is_valid = (
source_base_url is not None
and base_parts.scheme in ("http", "https")
and bool(base_parts.netloc)
)
if base_is_valid:
return {"status": "fixable", "resolved_url": urljoin(source_base_url, raw_url)}
return {"status": "needs_review", "resolved_url": None}
function isFixableImageUrl(rawUrl, sourceBaseUrl = null) {
let parts;
try {
parts = new URL(rawUrl || "", "relative://placeholder");
} catch {
return { status: "needs_review", resolvedUrl: null };
}
const hasRealScheme = parts.protocol !== "relative:" && parts.protocol !== "";
const scheme = hasRealScheme ? parts.protocol.replace(":", "") : "";
if ((scheme === "http" || scheme === "https") && parts.host) {
return { status: "already_valid", resolvedUrl: rawUrl };
}
if (hasRealScheme && scheme !== "http" && scheme !== "https") {
return { status: "unsupported_scheme", resolvedUrl: null };
}
let baseIsValid = false;
if (sourceBaseUrl) {
try {
const base = new URL(sourceBaseUrl);
baseIsValid = base.protocol === "http:" || base.protocol === "https:";
} catch {
baseIsValid = false;
}
}
if (baseIsValid) {
return { status: "fixable", resolvedUrl: new URL(rawUrl, sourceBaseUrl).href };
}
return { status: "needs_review", resolvedUrl: null };
}
Retry the image POST only for a resolved URL
When the decision is already_valid or fixable, call POST /v3/catalog/products/{product_id}/images with image_url set to the resolved URL and is_thumbnail true. Anything that comes back needs_review or unsupported_scheme is logged with the product id and the original image_url for a human to check, since guessing a host risks attaching the wrong image entirely.
def create_product_image(product_id, resolved_url):
return bc_post(
f"/catalog/products/{product_id}/images",
{"image_url": resolved_url, "is_thumbnail": True},
)
async function createProductImage(productId, resolvedUrl) {
return bcPost(`/catalog/products/${productId}/images`, {
image_url: resolvedUrl,
is_thumbnail: true,
});
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs the {product_id, original image_url, resolved_url, status} tuple for each product it would retry, and each product it would route to review. Read the output, confirm the resolved URLs actually point at real files, then switch it off.
Always start with DRY_RUN=true, and never invent a base URL just to force a fixable result. A product with the wrong image attached is a worse outcome than a product left with zero images and a clear review queue entry.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, lists zero-image products, resolves each recorded image_url with the pure decision function, and only retries the create-image call for URLs that are already valid or safely resolved, routing everything else to review.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find zero-image BigCommerce products and fix the non fully qualified image_url cause.
BigCommerce creates a product image by URL through
POST /v3/catalog/products/{product_id}/images with a JSON body containing
image_url, and BigCommerce's own servers fetch that remote file server-side.
Because of that server-side fetch, image_url must be a fully qualified absolute
URL, a scheme (http or https) plus a host. A relative path, a protocol-relative
URL, or a bare filename has no scheme or host for BigCommerce's fetcher to
resolve, so the request is rejected with a 422 image_url is invalid error. This
hits bulk or CSV migration imports the hardest, since the source system often
stored image paths relative to its own web root. The product record itself is
created before the image call runs, so the failed image row does not roll back
the product, it just leaves a real product with zero images and no automatic
retry.
This job lists every zero-image product, cross-references each one against the
import job's failure log for the original image_url, and uses a pure decision
function to classify it as already valid, fixable against a known source base
URL, or in need of human review. It only retries the create-image call for the
first two cases. Nothing is ever guessed; a URL that cannot be safely resolved
is routed to review instead of risking the wrong image on the wrong product.
Guide: https://www.allanninal.dev/bigcommerce/image-upload-rejects-relative-urls/
"""
import os
import logging
from urllib.parse import urlsplit, urljoin
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_relative_image_urls")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
SOURCE_BASE_URL = os.environ.get("SOURCE_BASE_URL") or None
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def is_fixable_image_url(raw_url: str, source_base_url: str | None = None) -> dict:
"""Pure decision. No network, no I/O.
If raw_url already parses with scheme http/https and a non-empty netloc,
it is already_valid. If raw_url has a scheme that is neither http nor
https (ftp, data, and so on), it is unsupported_scheme. Otherwise it is
treated as the non fully qualified defect: if source_base_url is itself a
valid absolute http/https URL, resolve raw_url against it with urljoin and
return fixable. If there is no usable base URL, return needs_review.
"""
parts = urlsplit(raw_url or "")
if parts.scheme in ("http", "https") and parts.netloc:
return {"status": "already_valid", "resolved_url": raw_url}
if parts.scheme and parts.scheme not in ("http", "https"):
return {"status": "unsupported_scheme", "resolved_url": None}
base_parts = urlsplit(source_base_url or "")
base_is_valid = (
source_base_url is not None
and base_parts.scheme in ("http", "https")
and bool(base_parts.netloc)
)
if base_is_valid:
return {"status": "fixable", "resolved_url": urljoin(source_base_url, raw_url)}
return {"status": "needs_review", "resolved_url": None}
def zero_image_products():
"""Page through products with images included, yielding the zero-image ones."""
page = 1
while True:
resp = bc_get("/catalog/products", {"include": "images", "limit": 250, "page": page})
products = resp.get("data") or []
if not products:
return
for product in products:
if not product.get("images"):
yield product
total_pages = resp.get("meta", {}).get("pagination", {}).get("total_pages", page)
if page >= total_pages:
return
page += 1
def failed_image_url_for(product_id, failure_log):
"""failure_log maps product_id -> original recorded image_url from the
import job's failure log. Returns None if this product has no matching
failure row, which means the zero-image state has some other cause."""
return failure_log.get(product_id)
def create_product_image(product_id, resolved_url):
return bc_post(
f"/catalog/products/{product_id}/images",
{"image_url": resolved_url, "is_thumbnail": True},
)
def run(failure_log=None):
failure_log = failure_log or {}
retried = 0
reviewed = 0
for product in zero_image_products():
product_id = product["id"]
raw_url = failed_image_url_for(product_id, failure_log)
if raw_url is None:
log.info("product_id=%s has zero images but no matching failure log entry, skipping", product_id)
continue
decision = is_fixable_image_url(raw_url, SOURCE_BASE_URL)
if decision["status"] in ("needs_review", "unsupported_scheme"):
log.warning(
"product_id=%s needs review. status=%s original_image_url=%s",
product_id, decision["status"], raw_url,
)
reviewed += 1
continue
resolved_url = decision["resolved_url"]
log.info(
"product_id=%s status=%s original_image_url=%s resolved_url=%s (%s)",
product_id, decision["status"], raw_url, resolved_url,
"dry run" if DRY_RUN else "retrying",
)
if not DRY_RUN:
create_product_image(product_id, resolved_url)
retried += 1
log.info(
"Done. %d product(s) %s, %d product(s) routed to review.",
retried, "to retry" if DRY_RUN else "retried", reviewed,
)
if __name__ == "__main__":
run()
/**
* Find zero-image BigCommerce products and fix the non fully qualified image_url cause.
*
* BigCommerce creates a product image by URL through
* POST /v3/catalog/products/{product_id}/images with a JSON body containing
* image_url, and BigCommerce's own servers fetch that remote file server-side.
* Because of that server-side fetch, image_url must be a fully qualified absolute
* URL, a scheme (http or https) plus a host. A relative path, a protocol-relative
* URL, or a bare filename has no scheme or host for BigCommerce's fetcher to
* resolve, so the request is rejected with a 422 image_url is invalid error. This
* hits bulk or CSV migration imports the hardest, since the source system often
* stored image paths relative to its own web root. The product record itself is
* created before the image call runs, so the failed image row does not roll back
* the product, it just leaves a real product with zero images and no automatic
* retry.
*
* This job lists every zero-image product, cross-references each one against the
* import job's failure log for the original image_url, and uses a pure decision
* function to classify it as already valid, fixable against a known source base
* URL, or in need of human review. It only retries the create-image call for the
* first two cases. Nothing is ever guessed; a URL that cannot be safely resolved
* is routed to review instead of risking the wrong image on the wrong product.
*
* Guide: https://www.allanninal.dev/bigcommerce/image-upload-rejects-relative-urls/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const SOURCE_BASE_URL = process.env.SOURCE_BASE_URL || null;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no I/O.
*
* If rawUrl already parses with protocol http/https and a non-empty host, it
* is already_valid. If rawUrl has a scheme that is neither http nor https
* (ftp, data, and so on), it is unsupported_scheme. Otherwise it is treated
* as the non fully qualified defect: if sourceBaseUrl is itself a valid
* absolute http/https URL, resolve rawUrl against it and return fixable. If
* there is no usable base URL, return needs_review.
*/
export function isFixableImageUrl(rawUrl, sourceBaseUrl = null) {
let parts;
let hasRealScheme = true;
try {
parts = new URL(rawUrl || "");
} catch {
hasRealScheme = false;
}
if (hasRealScheme) {
const scheme = parts.protocol.replace(":", "");
if ((scheme === "http" || scheme === "https") && parts.host) {
return { status: "already_valid", resolvedUrl: rawUrl };
}
if (scheme !== "http" && scheme !== "https") {
return { status: "unsupported_scheme", resolvedUrl: null };
}
}
let baseIsValid = false;
if (sourceBaseUrl) {
try {
const base = new URL(sourceBaseUrl);
baseIsValid = base.protocol === "http:" || base.protocol === "https:";
} catch {
baseIsValid = false;
}
}
if (baseIsValid) {
return { status: "fixable", resolvedUrl: new URL(rawUrl || "", sourceBaseUrl).href };
}
return { status: "needs_review", resolvedUrl: null };
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* zeroImageProducts() {
let page = 1;
while (true) {
const resp = await bcGet("/catalog/products", { include: "images", limit: 250, page });
const products = resp.data || [];
if (!products.length) return;
for (const product of products) {
if (!product.images || !product.images.length) yield product;
}
const totalPages = resp.meta?.pagination?.total_pages ?? page;
if (page >= totalPages) return;
page += 1;
}
}
function failedImageUrlFor(productId, failureLog) {
return failureLog[productId] ?? null;
}
async function createProductImage(productId, resolvedUrl) {
return bcPost(`/catalog/products/${productId}/images`, {
image_url: resolvedUrl,
is_thumbnail: true,
});
}
export async function run(failureLog = {}) {
let retried = 0;
let reviewed = 0;
for await (const product of zeroImageProducts()) {
const productId = product.id;
const rawUrl = failedImageUrlFor(productId, failureLog);
if (rawUrl === null) {
console.log(`product_id=${productId} has zero images but no matching failure log entry, skipping`);
continue;
}
const decision = isFixableImageUrl(rawUrl, SOURCE_BASE_URL);
if (decision.status === "needs_review" || decision.status === "unsupported_scheme") {
console.warn(`product_id=${productId} needs review. status=${decision.status} original_image_url=${rawUrl}`);
reviewed += 1;
continue;
}
const resolvedUrl = decision.resolvedUrl;
console.log(
`product_id=${productId} status=${decision.status} original_image_url=${rawUrl} ` +
`resolved_url=${resolvedUrl} (${DRY_RUN ? "dry run" : "retrying"})`
);
if (!DRY_RUN) await createProductImage(productId, resolvedUrl);
retried += 1;
}
console.log(
`Done. ${retried} product(s) ${DRY_RUN ? "to retry" : "retried"}, ${reviewed} product(s) routed to review.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a product gets a retried image call or a review flag. Because is_fixable_image_url takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in a URL and an optional base URL and checks the answer.
from fix_relative_image_urls import is_fixable_image_url
def test_root_relative_path_is_fixable_with_a_base_url():
result = is_fixable_image_url("/images/shoe.jpg", "https://cdn.example.com")
assert result == {"status": "fixable", "resolved_url": "https://cdn.example.com/images/shoe.jpg"}
def test_relative_path_with_no_base_needs_review():
result = is_fixable_image_url("images/shoe.jpg", None)
assert result["status"] == "needs_review"
assert result["resolved_url"] is None
def test_fully_qualified_url_is_already_valid():
result = is_fixable_image_url("https://cdn.example.com/shoe.jpg", None)
assert result == {"status": "already_valid", "resolved_url": "https://cdn.example.com/shoe.jpg"}
def test_protocol_relative_url_is_fixable_only_with_a_base_scheme():
no_base = is_fixable_image_url("//cdn.example.com/shoe.jpg", None)
assert no_base["status"] == "needs_review"
with_base = is_fixable_image_url("//cdn.example.com/shoe.jpg", "https://cdn.example.com")
assert with_base["status"] == "fixable"
assert with_base["resolved_url"] == "https://cdn.example.com/shoe.jpg"
def test_unsupported_scheme_is_never_fixable():
result = is_fixable_image_url("ftp://old.example.com/shoe.jpg", "https://cdn.example.com")
assert result == {"status": "unsupported_scheme", "resolved_url": None}
def test_bare_filename_with_a_base_url_is_fixable():
result = is_fixable_image_url("shoe.jpg", "https://cdn.example.com/images/")
assert result["status"] == "fixable"
assert result["resolved_url"] == "https://cdn.example.com/images/shoe.jpg"
def test_invalid_base_url_falls_back_to_needs_review():
result = is_fixable_image_url("/images/shoe.jpg", "not-a-real-base")
assert result["status"] == "needs_review"
assert result["resolved_url"] is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { isFixableImageUrl } from "./fix-relative-image-urls.js";
test("root-relative path is fixable with a base url", () => {
const result = isFixableImageUrl("/images/shoe.jpg", "https://cdn.example.com");
assert.deepEqual(result, { status: "fixable", resolvedUrl: "https://cdn.example.com/images/shoe.jpg" });
});
test("relative path with no base needs review", () => {
const result = isFixableImageUrl("images/shoe.jpg", null);
assert.equal(result.status, "needs_review");
assert.equal(result.resolvedUrl, null);
});
test("fully qualified url is already valid", () => {
const result = isFixableImageUrl("https://cdn.example.com/shoe.jpg", null);
assert.deepEqual(result, { status: "already_valid", resolvedUrl: "https://cdn.example.com/shoe.jpg" });
});
test("protocol-relative url is fixable only with a base scheme", () => {
const noBase = isFixableImageUrl("//cdn.example.com/shoe.jpg", null);
assert.equal(noBase.status, "needs_review");
const withBase = isFixableImageUrl("//cdn.example.com/shoe.jpg", "https://cdn.example.com");
assert.equal(withBase.status, "fixable");
assert.equal(withBase.resolvedUrl, "https://cdn.example.com/shoe.jpg");
});
test("unsupported scheme is never fixable", () => {
const result = isFixableImageUrl("ftp://old.example.com/shoe.jpg", "https://cdn.example.com");
assert.deepEqual(result, { status: "unsupported_scheme", resolvedUrl: null });
});
test("bare filename with a base url is fixable", () => {
const result = isFixableImageUrl("shoe.jpg", "https://cdn.example.com/images/");
assert.equal(result.status, "fixable");
assert.equal(result.resolvedUrl, "https://cdn.example.com/images/shoe.jpg");
});
test("invalid base url falls back to needs review", () => {
const result = isFixableImageUrl("/images/shoe.jpg", "not-a-real-base");
assert.equal(result.status, "needs_review");
assert.equal(result.resolvedUrl, null);
});
Case studies
The migration that copied web-root paths verbatim
A store moved from a legacy cart platform whose product export stored image paths the way its own theme referenced them, root-relative, like /media/catalog/product/shoe.jpg. The migration script created a few thousand products cleanly, then hit a 422 on every single image call, since none of those paths had a scheme or a host. The products all existed in BigCommerce, just with no pictures, and the migration log was the only place that recorded what the original path had been.
Running the reconciler against that failure log, with the legacy platform's own CDN domain set as SOURCE_BASE_URL, resolved almost every one of those root-relative paths to a real, fetchable URL. The handful that referenced images already deleted from the legacy CDN failed the retry with a normal 4xx from BigCommerce's fetcher, and those went to a short manual list instead of silently staying broken.
The spreadsheet that only ever had a filename column
A merchant's supplier sent product data as a spreadsheet with an Image column that just held a filename, shoe-red.jpg, on the assumption that whoever imported it would know where the actual files lived. A script that read that column straight into image_url got the same 422 image_url is invalid on every row, since a bare filename has neither scheme nor host.
Because the supplier confirmed that every file lived under one predictable folder on a shared asset host, that folder URL became the SOURCE_BASE_URL, and the fixable classification correctly resolved every bare filename to a working absolute URL. Rows where the merchant could not confirm a folder for a particular supplier were left flagged for review rather than guessed at.
After this runs, every zero-image product created by an import gets a clear answer. Products whose original image_url can be safely resolved against a known source base URL get a real image attached automatically. Products whose URL is genuinely ambiguous, a bad scheme, or missing a reliable base, land on a short human review list instead of being guessed at, so nobody ends up with the wrong picture on the wrong product.
FAQ
Why does BigCommerce reject my image_url with a 422 error?
BigCommerce creates a product image by URL through a server-side fetch, so image_url must be a fully qualified absolute URL with a scheme (http or https) and a host. A relative path, a protocol-relative URL, or a bare filename has no host for BigCommerce's fetcher to resolve, so the API rejects the request with a 422 image_url is invalid validation error.
Can BigCommerce guess the correct host for a relative image path?
No. BigCommerce has no way to infer which host a relative or root-relative path was meant to resolve against, so it cannot safely auto-correct the URL. Guessing risks attaching the wrong image to a product entirely, which is worse than leaving the product with zero images. The correct fix is to resolve the path against a known source base URL before retrying, or route it to a human when no reliable base URL exists.
Why does a failed image row still leave the product created?
Bulk or scripted imports typically create the product record with POST /v3/catalog/products first and then attach images with a separate POST /v3/catalog/products/{product_id}/images call per image. If the image call fails its 422 validation, only that image row fails. The product itself was already created successfully, so you end up with a real product that has zero images and no automatic retry for the missing image.
Related field notes
Citations
On the problem:
- BigCommerce Support Community: the field image_url is invalid. support.bigcommerce.com the-field-imageurl-is-invalid
- BigCommerce Support Community: uploading product via API but an error saying invalid fields image_url. support.bigcommerce.com uploading-product-via-bigcommerce-api
- BigCommerce Support Community: image_url is invalid 400 error when creating a product with a valid, live URL. support.bigcommerce.com i-am-getting-imageurl-is-invalid
On the solution:
- BigCommerce Developer Center: Product Images reference, the image_url field and create-by-URL behavior. developer.bigcommerce.com product-images
- BigCommerce Docs: List Products, the include=images parameter and pagination. docs.bigcommerce.com get-products
- BigCommerce Docs: List Product Images endpoint reference. docs.bigcommerce.com get-product-images
Stuck on a tricky one?
If you have a problem in BigCommerce catalog, products, imports, or migrations 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 fill in your missing product images?
If this caught a pile of zero-image products your import left behind, 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