Diagnostic Inventory and catalog
Broken product images in BigCommerce
A product page loads fine, the title and price are right, but the picture is a broken icon. BigCommerce still returns what looks like a valid image object from its API. The row in the catalog is there. The file it points to is not. Here is why an image record can outlive its own file and a small script that finds every one of these and fixes it without guessing.
BigCommerce keeps an image record on /v3/catalog/products/{product_id}/images with an image_url and derived url_zoom, url_standard, url_thumbnail, and url_tiny fields, but the file those URLs point to can go missing while the row survives, most often after a bulk import, a PIM or WMS sync, or a botched CDN migration. Run a small Python or Node.js script that pages through GET /v3/catalog/products?include=images&limit=250, checks the status of every image URL, and flags the broken ones. It never deletes a row on sight. Under an explicit DRY_RUN=false flag it clears or removes only a confirmed dead reference, and promotes the next image to thumbnail if the one removed was the product's thumbnail. Full code, tests, and a dry run guard are below.
The problem in plain words
Every product image in BigCommerce is really two things: a database row that holds metadata and a set of CDN URLs, and a file that BigCommerce's image service is supposed to be able to serve at those URLs. As long as both exist together, the storefront shows a picture. The trouble starts when one of them disappears and the other does not.
The row is easy to keep. It just sits in the catalog with an id, a sort order, an image_url, and the derived URLs. The file is the fragile part. If it is ever deleted, renamed, or was never actually there in the first place, the row has no idea. GET /v3/catalog/products/{product_id}/images keeps returning what looks like a perfectly normal image object, because BigCommerce is only reporting what it has stored about the image, not whether the image itself still loads. The storefront asks for url_standard, gets a 404, and renders a broken <img>.
Why it happens
BigCommerce's images sub-resource stores metadata and CDN URLs, but it does not continuously verify that the file behind those URLs still exists. A few common ways stores end up with broken images anyway:
- A bulk CSV or V2 product import sets
image_urlto an external or since-removed URL that BigCommerce was supposed to fetch and store, but the fetch never actually completed or the source URL was already dead by the time the import ran. - A WMS or PIM sync writes a new image row that references a source file which gets deleted or renamed on the source system before BigCommerce's image service had a chance to pull it in.
- A botched Stencil or CDN migration, or a third-party app cleanup, purges files directly from storage without deleting the corresponding image records in the catalog, so the row is left pointing at nothing.
- Any of the above happening on a product that is
is_thumbnail: true, so the broken image is exactly the one driving the catalog and category grid thumbnail, not just a secondary gallery photo.
This is a common source of confusion because GET /v3/catalog/products/{product_id}/images gives no hint that anything is wrong. The image object has an id, a sort order, and URLs that look completely normal. The only way to know a file is actually gone is to ask the URL itself. See the citations at the end for the exact docs and reports.
A broken image row is not something a script should silently delete. It can be the only reference a merchant or an outside PIM system is tracking for that product's photo, so removing it blind can erase information nobody meant to lose. The safe pattern is to flag every broken image for review by default, and only clear the URL or delete the row under an explicit DRY_RUN=false flag, once you have decided whether a known-good replacement exists.
The fix, as a flow
We do not touch live images by default. We add a job that pages through the catalog with its images, checks the status of every image URL, and classifies each image with a pure decision function. In dry run, which is the default, it only reports what it found. In write mode it clears a broken reference, deletes a dead one with no replacement, and promotes the next image to thumbnail if the one removed was the product's thumbnail.
Build it step by step
Get a store hash and an access token
Create an API account in your BigCommerce control panel under Settings, API, Store-level API accounts. Give it read access to Products, and write access if you plan to run with DRY_RUN=false. Keep the store hash and access token in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_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="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
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}/ with your token in the X-Auth-Token header. A small helper sends the request, raises on a bad status, and unwraps the data envelope that every V3 response wraps its payload in.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
if not r.content:
return None
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return null;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
Page through every product and its images
Ask for GET /v3/catalog/products?include=images&limit=250&page=n and keep asking for the next page until a page comes back shorter than the limit, which respects the V3 meta.pagination contract. Each image object carries its own id, image_url, url_standard, is_thumbnail, and sort_order.
def all_products():
"""Yield every product with its images, paginated."""
page = 1
limit = 250
while True:
batch = bc("GET", f"/v3/catalog/products?include=images&limit={limit}&page={page}")
if not batch:
return
for product in batch:
yield product
if len(batch) < limit:
return
page += 1
async function* allProducts() {
const limit = 250;
let page = 1;
while (true) {
const batch = await bc("GET", `/v3/catalog/products?include=images&limit=${limit}&page=${page}`);
if (!batch || !batch.length) return;
for (const product of batch) yield product;
if (batch.length < limit) return;
page += 1;
}
}
Check the URL, then decide with one pure function
Checking a URL's status is I/O, so that happens outside the decision. The caller does a HEAD or GET on image_url or url_standard and hands the resulting status code in as a plain mapping. The decision itself takes only that in-memory data: the image record, the URL status map, and the sibling images still on the product. It returns ok when the URL is well-formed and answers 2xx, flag_only when the URL is missing or malformed with no safe replacement known, clear_reference when the URL is confirmed 404 or 403 and the image is not the last one on the product, and promote_thumbnail when the broken image is is_thumbnail: true and another good image remains. Because it touches no network and no database, it is fully unit-testable.
import re
_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
def _is_malformed(url):
return not isinstance(url, str) or not url.strip() or not _URL_RE.match(url.strip())
def decide_image_action(image, url_status, remaining_images):
url = image.get("url_standard") or image.get("image_url")
if _is_malformed(url):
return "flag_only"
status = url_status.get(url)
if status is not None and 200 <= status < 300:
return "ok"
if status not in (403, 404):
return "flag_only"
siblings = [i for i in remaining_images if i.get("id") != image.get("id")]
if not siblings:
return "flag_only"
if image.get("is_thumbnail") and any(not _is_malformed(s.get("url_standard") or s.get("image_url"))
and url_status.get(s.get("url_standard") or s.get("image_url"), 200) not in (403, 404)
for s in siblings):
return "promote_thumbnail"
return "clear_reference"
const URL_RE = /^https?:\/\//i;
function isMalformed(url) {
return typeof url !== "string" || !url.trim() || !URL_RE.test(url.trim());
}
export function decideImageAction(image, urlStatus, remainingImages) {
const url = image.url_standard || image.image_url;
if (isMalformed(url)) return "flag_only";
const status = urlStatus[url];
if (status !== undefined && status !== null && status >= 200 && status < 300) return "ok";
if (status !== 403 && status !== 404) return "flag_only";
const siblings = remainingImages.filter((i) => i.id !== image.id);
if (siblings.length === 0) return "flag_only";
const hasGoodSibling = siblings.some((s) => {
const sUrl = s.url_standard || s.image_url;
if (isMalformed(sUrl)) return false;
const sStatus = urlStatus[sUrl];
return sStatus === undefined || sStatus === null || (sStatus !== 403 && sStatus !== 404);
});
if (image.is_thumbnail && hasGoodSibling) return "promote_thumbnail";
return "clear_reference";
}
Apply the decision, one call per action
When write mode is on, a clear_reference logs the full before-state and then either self-heals with PUT /v3/catalog/products/{product_id}/images/{image_id} if a known-good source is passed in, or falls back to DELETE /v3/catalog/products/{product_id}/images/{image_id} when there is nothing to re-supply. A promote_thumbnail also PUTs is_thumbnail: true onto the next-lowest sort_order remaining image so the product keeps a valid catalog thumbnail.
def clear_reference(product_id, image_id, replacement_url=None):
if replacement_url:
return bc("PUT", f"/v3/catalog/products/{product_id}/images/{image_id}",
json={"image_url": replacement_url})
return bc("DELETE", f"/v3/catalog/products/{product_id}/images/{image_id}")
def promote_thumbnail(product_id, remaining_images, broken_image_id):
candidates = sorted(
(i for i in remaining_images if i.get("id") != broken_image_id),
key=lambda i: i.get("sort_order", 0),
)
if not candidates:
return None
next_image = candidates[0]
return bc("PUT", f"/v3/catalog/products/{product_id}/images/{next_image['id']}",
json={"is_thumbnail": True})
async function clearReference(productId, imageId, replacementUrl) {
if (replacementUrl) {
return bc("PUT", `/v3/catalog/products/${productId}/images/${imageId}`, { image_url: replacementUrl });
}
return bc("DELETE", `/v3/catalog/products/${productId}/images/${imageId}`);
}
async function promoteThumbnail(productId, remainingImages, brokenImageId) {
const candidates = remainingImages
.filter((i) => i.id !== brokenImageId)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
if (candidates.length === 0) return null;
const nextImage = candidates[0];
return bc("PUT", `/v3/catalog/products/${productId}/images/${nextImage.id}`, { is_thumbnail: true });
}
Wire it together with a dry run guard
The loop pages the catalog, checks every image URL's status, feeds the result into decide_image_action, and only calls clear_reference or promote_thumbnail when DRY_RUN is false. On the first few runs, leave DRY_RUN on so it only prints the plan: product id, image id, sort order, and the failing URL. Read the output, confirm it against the Admin, then switch it off. Run it on a schedule that matches how often your catalog or CDN changes, for example nightly.
Always start with DRY_RUN=true. The script never deletes an image row it has not first confirmed is a non-2xx status, and it never invents a replacement URL, so a clear_reference without a known-good source falls back to a logged delete rather than a silent guess.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs the before-state of everything it touches, respects the dry run flag, and is safe to run again and again because it only acts on images it has confirmed are broken by checking their status directly.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and repair broken BigCommerce product images.
A product image record on /v3/catalog/products/{product_id}/images stores
metadata and derived CDN URLs (url_zoom, url_standard, url_thumbnail, url_tiny)
that point at a file BigCommerce's image service is expected to serve, but the
underlying file can go missing while the database row survives. This commonly
follows a bulk CSV/V2 import that set image_url to a URL that was never truly
fetched, a WMS/PIM sync that wrote a row referencing a file deleted or renamed
before BigCommerce could pull it, or a botched Stencil/CDN migration or app
cleanup that purges files without deleting the matching image records.
This pages through GET /v3/catalog/products?include=images&limit=250 across
the full catalog, checks the status of every image URL, classifies each image
with a pure decision function, and in write mode clears a confirmed-dead
reference (self-healing with a replacement URL if one is known, otherwise
deleting the row) and promotes the next image to thumbnail if the one removed
was the product's thumbnail. It never deletes on sight: everything is flagged
for review by default. Guarded by DRY_RUN. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/broken-product-images/
"""
import os
import re
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_broken_images")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Optional map of broken image_url -> known-good replacement URL, comma-separated
# pairs "old=>new". Left empty by default so broken images with no confirmed
# replacement fall back to a logged delete instead of a guess.
_raw_replacements = os.environ.get("REPLACEMENT_URLS", "")
REPLACEMENT_URLS = {}
for pair in _raw_replacements.split(","):
if "=>" in pair:
old, new = pair.split("=>", 1)
if old.strip() and new.strip():
REPLACEMENT_URLS[old.strip()] = new.strip()
_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
if not r.content:
return None
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
def _is_malformed(url):
return not isinstance(url, str) or not url.strip() or not _URL_RE.match(url.strip())
def decide_image_action(image, url_status, remaining_images):
"""Pure decision. No network calls, no side effects.
image: {"id", "image_url", "url_standard", "is_thumbnail", "sort_order"}
url_status: mapping of URL -> HTTP status code (or None if unreachable),
obtained by the caller beforehand.
remaining_images: sibling images still on the product.
Returns "ok", "flag_only", "clear_reference", or "promote_thumbnail".
"""
url = image.get("url_standard") or image.get("image_url")
if _is_malformed(url):
return "flag_only"
status = url_status.get(url)
if status is not None and 200 <= status < 300:
return "ok"
if status not in (403, 404):
return "flag_only"
siblings = [i for i in remaining_images if i.get("id") != image.get("id")]
if not siblings:
return "flag_only"
def sibling_ok(s):
s_url = s.get("url_standard") or s.get("image_url")
if _is_malformed(s_url):
return False
s_status = url_status.get(s_url)
return s_status is None or s_status not in (403, 404)
if image.get("is_thumbnail") and any(sibling_ok(s) for s in siblings):
return "promote_thumbnail"
return "clear_reference"
def check_url_status(url):
"""I/O helper: HEAD (falling back to GET) a URL and return its status code,
or None if the request could not complete at all."""
try:
r = requests.head(url, timeout=15, allow_redirects=True)
if r.status_code == 405:
r = requests.get(url, timeout=15, stream=True)
return r.status_code
except requests.RequestException:
return None
def all_products():
page = 1
limit = 250
while True:
batch = bc("GET", f"/v3/catalog/products?include=images&limit={limit}&page={page}")
if not batch:
return
for product in batch:
yield product
if len(batch) < limit:
return
page += 1
def clear_reference(product_id, image_id, replacement_url=None):
if replacement_url:
return bc("PUT", f"/v3/catalog/products/{product_id}/images/{image_id}",
json={"image_url": replacement_url})
return bc("DELETE", f"/v3/catalog/products/{product_id}/images/{image_id}")
def promote_thumbnail(product_id, remaining_images, broken_image_id):
candidates = sorted(
(i for i in remaining_images if i.get("id") != broken_image_id),
key=lambda i: i.get("sort_order", 0),
)
if not candidates:
return None
next_image = candidates[0]
return bc("PUT", f"/v3/catalog/products/{product_id}/images/{next_image['id']}",
json={"is_thumbnail": True})
def run():
flagged = 0
cleared = 0
promoted = 0
for product in all_products():
images = product.get("images") or []
url_status = {}
for image in images:
url = image.get("url_standard") or image.get("image_url")
if url and not _is_malformed(url):
url_status[url] = check_url_status(url)
for image in images:
action = decide_image_action(image, url_status, images)
if action == "ok":
continue
url = image.get("url_standard") or image.get("image_url")
log.warning(
"product=%s image=%s sort_order=%s action=%s url=%r before=%r",
product["id"], image.get("id"), image.get("sort_order"), action, url, image,
)
flagged += 1
if DRY_RUN:
continue
if action == "clear_reference":
replacement = REPLACEMENT_URLS.get(url)
clear_reference(product["id"], image["id"], replacement)
cleared += 1
elif action == "promote_thumbnail":
replacement = REPLACEMENT_URLS.get(url)
clear_reference(product["id"], image["id"], replacement)
promote_thumbnail(product["id"], images, image["id"])
cleared += 1
promoted += 1
log.info(
"Done. %d image(s) flagged, %d cleared, %d thumbnail(s) promoted. (%s)",
flagged, cleared, promoted, "dry run" if DRY_RUN else "write mode",
)
if __name__ == "__main__":
run()
/**
* Find and repair broken BigCommerce product images.
*
* A product image record on /v3/catalog/products/{product_id}/images stores
* metadata and derived CDN URLs (url_zoom, url_standard, url_thumbnail, url_tiny)
* that point at a file BigCommerce's image service is expected to serve, but the
* underlying file can go missing while the database row survives. This commonly
* follows a bulk CSV/V2 import that set image_url to a URL that was never truly
* fetched, a WMS/PIM sync that wrote a row referencing a file deleted or renamed
* before BigCommerce could pull it, or a botched Stencil/CDN migration or app
* cleanup that purges files without deleting the matching image records.
*
* This pages through GET /v3/catalog/products?include=images&limit=250 across
* the full catalog, checks the status of every image URL, classifies each image
* with a pure decision function, and in write mode clears a confirmed-dead
* reference (self-healing with a replacement URL if one is known, otherwise
* deleting the row) and promotes the next image to thumbnail if the one removed
* was the product's thumbnail. It never deletes on sight. Guarded by DRY_RUN.
* Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/broken-product-images/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "dummy_store_hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Optional map of broken image_url -> known-good replacement URL, comma-separated
// pairs "old=>new". Left empty by default so broken images with no confirmed
// replacement fall back to a logged delete instead of a guess.
const REPLACEMENT_URLS = {};
for (const pair of (process.env.REPLACEMENT_URLS || "").split(",")) {
const idx = pair.indexOf("=>");
if (idx === -1) continue;
const oldUrl = pair.slice(0, idx).trim();
const newUrl = pair.slice(idx + 2).trim();
if (oldUrl && newUrl) REPLACEMENT_URLS[oldUrl] = newUrl;
}
const URL_RE = /^https?:\/\//i;
function isMalformed(url) {
return typeof url !== "string" || !url.trim() || !URL_RE.test(url.trim());
}
/**
* Pure decision. No network calls, no side effects.
*
* image: { id, image_url, url_standard, is_thumbnail, sort_order }
* urlStatus: mapping of URL -> HTTP status code (or null if unreachable),
* obtained by the caller beforehand.
* remainingImages: sibling images still on the product.
*
* Returns "ok", "flag_only", "clear_reference", or "promote_thumbnail".
*/
export function decideImageAction(image, urlStatus, remainingImages) {
const url = image.url_standard || image.image_url;
if (isMalformed(url)) return "flag_only";
const status = urlStatus[url];
if (status !== undefined && status !== null && status >= 200 && status < 300) return "ok";
if (status !== 403 && status !== 404) return "flag_only";
const siblings = remainingImages.filter((i) => i.id !== image.id);
if (siblings.length === 0) return "flag_only";
const hasGoodSibling = siblings.some((s) => {
const sUrl = s.url_standard || s.image_url;
if (isMalformed(sUrl)) return false;
const sStatus = urlStatus[sUrl];
return sStatus === undefined || sStatus === null || (sStatus !== 403 && sStatus !== 404);
});
if (image.is_thumbnail && hasGoodSibling) return "promote_thumbnail";
return "clear_reference";
}
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return null;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
/** I/O helper: HEAD (falling back to GET) a URL and return its status code,
* or null if the request could not complete at all. */
async function checkUrlStatus(url) {
try {
let res = await fetch(url, { method: "HEAD", redirect: "follow" });
if (res.status === 405) res = await fetch(url, { method: "GET", redirect: "follow" });
return res.status;
} catch {
return null;
}
}
async function* allProducts() {
const limit = 250;
let page = 1;
while (true) {
const batch = await bc("GET", `/v3/catalog/products?include=images&limit=${limit}&page=${page}`);
if (!batch || !batch.length) return;
for (const product of batch) yield product;
if (batch.length < limit) return;
page += 1;
}
}
async function clearReference(productId, imageId, replacementUrl) {
if (replacementUrl) {
return bc("PUT", `/v3/catalog/products/${productId}/images/${imageId}`, { image_url: replacementUrl });
}
return bc("DELETE", `/v3/catalog/products/${productId}/images/${imageId}`);
}
async function promoteThumbnail(productId, remainingImages, brokenImageId) {
const candidates = remainingImages
.filter((i) => i.id !== brokenImageId)
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
if (candidates.length === 0) return null;
const nextImage = candidates[0];
return bc("PUT", `/v3/catalog/products/${productId}/images/${nextImage.id}`, { is_thumbnail: true });
}
export async function run() {
let flagged = 0;
let cleared = 0;
let promoted = 0;
for await (const product of allProducts()) {
const images = product.images || [];
const urlStatus = {};
for (const image of images) {
const url = image.url_standard || image.image_url;
if (url && !isMalformed(url)) {
urlStatus[url] = await checkUrlStatus(url);
}
}
for (const image of images) {
const action = decideImageAction(image, urlStatus, images);
if (action === "ok") continue;
const url = image.url_standard || image.image_url;
console.warn(
`product=${product.id} image=${image.id} sort_order=${image.sort_order} action=${action} url=${JSON.stringify(url)} before=${JSON.stringify(image)}`
);
flagged++;
if (DRY_RUN) continue;
if (action === "clear_reference") {
const replacement = REPLACEMENT_URLS[url];
await clearReference(product.id, image.id, replacement);
cleared++;
} else if (action === "promote_thumbnail") {
const replacement = REPLACEMENT_URLS[url];
await clearReference(product.id, image.id, replacement);
await promoteThumbnail(product.id, images, image.id);
cleared++;
promoted++;
}
}
}
console.log(
`Done. ${flagged} image(s) flagged, ${cleared} cleared, ${promoted} thumbnail(s) promoted. (${DRY_RUN ? "dry run" : "write mode"})`
);
}
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 what gets flagged, cleared, or promoted. Because we kept decide_image_action pure, the test needs no network and no BigCommerce store. It just feeds in plain records and a status map and checks the answer.
from find_broken_images import decide_image_action
def image(**over):
base = {
"id": 1,
"image_url": "https://cdn.example.com/a.jpg",
"url_standard": "https://cdn.example.com/a.jpg",
"is_thumbnail": False,
"sort_order": 0,
}
base.update(over)
return base
def test_ok_when_status_is_2xx():
img = image()
status = {img["url_standard"]: 200}
assert decide_image_action(img, status, [img]) == "ok"
def test_flag_only_when_url_missing():
img = image(url_standard=None, image_url=None)
assert decide_image_action(img, {}, [img]) == "flag_only"
def test_flag_only_when_url_malformed():
img = image(url_standard="not-a-url")
assert decide_image_action(img, {"not-a-url": 404}, [img]) == "flag_only"
def test_clear_reference_when_404_and_not_last_image():
img = image(id=1)
sibling = image(id=2, url_standard="https://cdn.example.com/b.jpg")
status = {img["url_standard"]: 404, sibling["url_standard"]: 200}
assert decide_image_action(img, status, [img, sibling]) == "clear_reference"
def test_flag_only_when_404_and_only_image_on_product():
img = image(id=1)
status = {img["url_standard"]: 404}
assert decide_image_action(img, status, [img]) == "flag_only"
def test_promote_thumbnail_when_broken_thumbnail_has_good_sibling():
img = image(id=1, is_thumbnail=True)
sibling = image(id=2, url_standard="https://cdn.example.com/b.jpg", sort_order=1)
status = {img["url_standard"]: 403, sibling["url_standard"]: 200}
assert decide_image_action(img, status, [img, sibling]) == "promote_thumbnail"
def test_clear_reference_when_broken_thumbnail_has_no_good_sibling():
img = image(id=1, is_thumbnail=True)
sibling = image(id=2, url_standard="https://cdn.example.com/b.jpg", sort_order=1)
status = {img["url_standard"]: 404, sibling["url_standard"]: 404}
assert decide_image_action(img, status, [img, sibling]) == "clear_reference"
def test_flag_only_when_status_is_unreachable_but_not_403_or_404():
img = image()
status = {img["url_standard"]: 500}
assert decide_image_action(img, status, [img]) == "flag_only"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideImageAction } from "./find-broken-images.js";
const image = (over = {}) => ({
id: 1,
image_url: "https://cdn.example.com/a.jpg",
url_standard: "https://cdn.example.com/a.jpg",
is_thumbnail: false,
sort_order: 0,
...over,
});
test("ok when status is 2xx", () => {
const img = image();
const status = { [img.url_standard]: 200 };
assert.equal(decideImageAction(img, status, [img]), "ok");
});
test("flag only when url missing", () => {
const img = image({ url_standard: null, image_url: null });
assert.equal(decideImageAction(img, {}, [img]), "flag_only");
});
test("flag only when url malformed", () => {
const img = image({ url_standard: "not-a-url" });
const status = { "not-a-url": 404 };
assert.equal(decideImageAction(img, status, [img]), "flag_only");
});
test("clear reference when 404 and not last image", () => {
const img = image({ id: 1 });
const sibling = image({ id: 2, url_standard: "https://cdn.example.com/b.jpg" });
const status = { [img.url_standard]: 404, [sibling.url_standard]: 200 };
assert.equal(decideImageAction(img, status, [img, sibling]), "clear_reference");
});
test("flag only when 404 and only image on product", () => {
const img = image({ id: 1 });
const status = { [img.url_standard]: 404 };
assert.equal(decideImageAction(img, status, [img]), "flag_only");
});
test("promote thumbnail when broken thumbnail has good sibling", () => {
const img = image({ id: 1, is_thumbnail: true });
const sibling = image({ id: 2, url_standard: "https://cdn.example.com/b.jpg", sort_order: 1 });
const status = { [img.url_standard]: 403, [sibling.url_standard]: 200 };
assert.equal(decideImageAction(img, status, [img, sibling]), "promote_thumbnail");
});
test("clear reference when broken thumbnail has no good sibling", () => {
const img = image({ id: 1, is_thumbnail: true });
const sibling = image({ id: 2, url_standard: "https://cdn.example.com/b.jpg", sort_order: 1 });
const status = { [img.url_standard]: 404, [sibling.url_standard]: 404 };
assert.equal(decideImageAction(img, status, [img, sibling]), "clear_reference");
});
test("flag only when status is unreachable but not 403 or 404", () => {
const img = image();
const status = { [img.url_standard]: 500 };
assert.equal(decideImageAction(img, status, [img]), "flag_only");
});
Case studies
The migration that pointed at URLs that never existed
An outdoor gear store migrated a few thousand products from another platform with a CSV that set image_url to links copied straight out of the old system's export. A subset of those links were already dead by the time the import ran, but BigCommerce still created an image row for every one, so the catalog looked complete in the Admin's product list.
Running the script in dry run surfaced the exact product ids, image ids, and the failing URLs in one pass. The team supplied real replacement images for the products that mattered most through REPLACEMENT_URLS, and let the rest fall back to a clean delete instead of leaving a 404 icon on the storefront.
The thumbnail that vanished from every category page
A home goods retailer moved image storage to a new CDN and a script on the old side deleted files as it went, but it never touched the BigCommerce image records that referenced them. A large share of category grids started showing broken icons where thumbnails used to be, since the broken image happened to be marked is_thumbnail: true on many products.
The nightly job caught every one of those 404s, and because a working gallery photo still existed on most of the affected products, it promoted the next image to thumbnail automatically once running in write mode. Category pages recovered without anyone manually re-picking a thumbnail for each product.
After this runs on a schedule, a file that quietly disappeared behind an image record never sits there rendering a broken icon for weeks. Every broken image gets logged with its full before-state the same run it is found, and a review-first default means nothing gets deleted until you decide it should be. Thumbnails recover on their own when a good photo is still available, and the storefront stops showing a 404 where a picture should be.
FAQ
Why does a BigCommerce product image show as broken on the storefront?
BigCommerce stores an image record with a URL that its image service is expected to serve, but the underlying file can go missing while the database row survives. That happens most often after a bulk CSV or V2 import that referenced a source URL that was never actually fetched, after a WMS or PIM sync wrote an image row pointing at a file that was deleted or renamed, or after a CDN migration or app cleanup purged files without deleting the matching image records.
How do you find broken product images across a whole BigCommerce catalog?
Page through GET /v3/catalog/products?include=images with the V3 Management API and check every image object's image_url and url_standard fields. Flag an image when the URL is empty, malformed, or a HEAD or GET request to it returns a non-2xx status or a content type that is not image, and follow meta.pagination to walk the full catalog.
Is it safe to auto-fix broken product images with a script?
Not by deleting on sight. The image row can be the only reference a merchant or PIM system is tracking, so the safe pattern is to flag every broken image for review by default, and only clear or delete a confirmed dead reference under an explicit DRY_RUN=false flag, promoting the next image to thumbnail if the one removed was the product's thumbnail.
Related field notes
Citations
On the problem:
- BigCommerce Developer Center: Images, the product images sub-resource. developer.bigcommerce.com rest-catalog products images
- BigCommerce Developer Center: V2 to V3 Catalog Operations Comparison migration guide. developer.bigcommerce.com store-operations catalog migration guide
- GitHub bigcommerce/stencil-cli: stencil download pull command corrupts image assets. github.com bigcommerce/stencil-cli issues/670
On the solution:
- BigCommerce Docs: List Product Images, the REST Admin API reference. docs.bigcommerce.com catalog products images get-product-images
- BigCommerce Developer Center: Catalog Overview. developer.bigcommerce.com store-management catalog catalog-overview
- BigCommerce Developer Center: Difference between V2 and V3 Catalog REST APIs. developer.bigcommerce.com store-operations catalog migration
Stuck on a tricky one?
If you have a problem in BigCommerce orders, catalog, 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 clean up your catalog?
If this saved you a pile of manual clicks or a broken storefront image, 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