Diagnostic Catalog / Products
BigCommerce category image_file field rejected as invalid on update
A sync script PUTs a category update with an image_file field and BigCommerce answers with a flat 400, the field 'image_file' is invalid. The category's image never changes, and depending on the payload the whole call can fail. The cause is simple once you see it: the V3 Catalog Categories JSON endpoint only ever accepts image_url. image_file only exists on a separate multipart endpoint. Here is why the two get confused and a small script that picks the right one automatically.
BigCommerce's V3 Catalog Categories JSON endpoint, PUT https://api.bigcommerce.com/stores/{store_hash}/v3/catalog/categories, has no image_file property in its schema. It only accepts image_url for setting or replacing a category's image. image_file is a real field, but it belongs to the separate multipart endpoint, POST /v3/catalog/categories/{category_id}/image, which needs Content-Type: multipart/form-data, not JSON. Send image_url on the JSON PUT when you have a public https URL, or send image_file as multipart to the image endpoint when you only have raw file bytes. Never send image_file as JSON. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce's Catalog V3 API has two different ways to touch a category's image, and they are not interchangeable. The categories resource itself, /v3/catalog/categories, is a plain JSON resource. Its schema exposes image_url as a string field, and that is the only way to point a category at an image through that endpoint, whether you are creating the category or updating it.
The other path is the dedicated category image endpoint, /v3/catalog/categories/{category_id}/image. That one takes a multipart/form-data body with a file field literally named image_file, because it is receiving actual bytes, not a URL string. The name image_file is documented and correct, just not for the JSON resource.
A sync script that reads "image_file" as the field name, whether it copied that from the multipart docs, from older V2 documentation, or from a teammate's half-remembered notes, and then PUTs it inside the JSON body of a categories update, runs straight into a schema that has never heard of that property. BigCommerce responds with a 400 and a message like The field 'image_file' is invalid. The category's image silently fails to update, and if the field is part of a larger batch payload, the 400 can take the rest of that call down with it.
Why it happens
BigCommerce splits image handling across two resources on purpose, and a few common habits make it easy to send the wrong field to the wrong one:
- Copying the field name from the multipart category image endpoint's docs (
POST /v3/catalog/categories/{category_id}/image, which really does useimage_file) and reusing it on the JSON PUT to/v3/catalog/categories, which has no such property. - Carrying over field naming from older, muddled V2-era documentation or internal notes that used "image_file" loosely for what V3 categories now expose strictly as
image_url. - A bulk sync job that builds one payload shape for every field it manages and never branches on whether the image source is a public URL versus raw file bytes, so it always reaches for the same field name regardless of endpoint.
- Not reading the actual 400 response body closely. The message names the exact field,
'image_file' is invalid, but when it is buried in a large batch response or only logged as a status code, that detail gets missed and the failure looks like a mysterious sync bug instead of a wrong field name.
The result matches reports from other integrators hitting the same 400 on category image updates through the API. See the citations at the end for the exact issue threads and docs.
The categories JSON resource and the category image resource are not one API with two names for the same field. They are two different endpoints with two different content types, and each one only understands its own field. image_url belongs to the JSON PUT. image_file belongs to the multipart POST. The fix is not a different field name, it is choosing the correct endpoint for the image source you actually have, a public URL or raw file bytes, and never letting image_file anywhere near a JSON body.
The fix, as a flow
We add a small repair step that looks at each category's current image_url and the image source we have on file for it, decides whether the image needs fixing, and if so picks the one correct call for that source, JSON image_url or multipart image_file, never both, never the wrong pairing.
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 (modify) scope so it can read and update categories and upload 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 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 Categories REST API
Every JSON call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/catalog/categories with the token in the X-Auth-Token header and Accept: application/json. A small helper handles GET and PUT and raises on a non-2xx response. The multipart image upload gets its own helper because it sends a file, not a JSON body.
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_put_json(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def bc_post_multipart(path, file_path):
headers = {"X-Auth-Token": ACCESS_TOKEN, "Accept": "application/json"}
with open(file_path, "rb") as fh:
files = {"image_file": (os.path.basename(file_path), fh)}
r = requests.post(f"{API_BASE}{path}", headers=headers, files=files, timeout=60)
r.raise_for_status()
return r.json()
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
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 bcPutJson(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPostMultipart(path, filePath) {
const bytes = await readFile(filePath);
const form = new FormData();
form.append("image_file", new Blob([bytes]), basename(filePath));
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "X-Auth-Token": ACCESS_TOKEN, Accept: "application/json" },
body: form,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
List categories and read their current image_url
Call GET /v3/catalog/categories?limit=250&page=N, paginated with meta.pagination.total_pages, and read data[].image_url for each record. A category with a missing or stale image_url compared to your source-of-truth catalog is a candidate for repair.
def all_categories():
page = 1
while True:
result = bc_get("/catalog/categories", {"limit": 250, "page": page})
for category in result.get("data", []):
yield category
pagination = result.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", page):
return
page += 1
async function* allCategories() {
let page = 1;
while (true) {
const result = await bcGet("/catalog/categories", { limit: 250, page });
for (const category of result.data || []) yield category;
const pagination = result.meta?.pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the category's current state and the image source you have on file, and returns exactly one repair strategy. If a public URL exists and differs from (or is missing on) the category, use the JSON image_url path. Only fall back to the multipart image_file path when there is no public URL but a local file exists. If neither source exists, flag it. The function must never pair field: "image_file" with the JSON action, that pairing is the exact bug we are guarding against.
def choose_image_repair_strategy(category: dict, image_source: dict) -> dict:
current_url = category.get("image_url")
public_url = image_source.get("public_url")
local_file_path = image_source.get("local_file_path")
if public_url and (not current_url or current_url != public_url):
return {
"action": "put_image_url",
"endpoint": "/v3/catalog/categories",
"field": "image_url",
"value": public_url,
}
if local_file_path:
return {
"action": "post_multipart_image",
"endpoint": f"/v3/catalog/categories/{category['id']}/image",
"field": "image_file",
"value": local_file_path,
}
return {"action": "flag", "reason": "no_image_source_available"}
export function chooseImageRepairStrategy(category, imageSource) {
const currentUrl = category.image_url;
const publicUrl = imageSource.public_url;
const localFilePath = imageSource.local_file_path;
if (publicUrl && (!currentUrl || currentUrl !== publicUrl)) {
return {
action: "put_image_url",
endpoint: "/v3/catalog/categories",
field: "image_url",
value: publicUrl,
};
}
if (localFilePath) {
return {
action: "post_multipart_image",
endpoint: `/v3/catalog/categories/${category.id}/image`,
field: "image_file",
value: localFilePath,
};
}
return { action: "flag", reason: "no_image_source_available" };
}
Apply the chosen strategy, never image_file on the JSON path
When the decision is put_image_url, call PUT /v3/catalog/categories with a JSON body of [{"id": category_id, "image_url": value}]. When it is post_multipart_image, call POST /v3/catalog/categories/{category_id}/image as multipart/form-data with the file under the field name image_file. A flag decision writes nothing, it only records the category for a human to source an image for.
def apply_repair(category_id, strategy):
if strategy["action"] == "put_image_url":
return bc_put_json("/catalog/categories", [{"id": category_id, "image_url": strategy["value"]}])
if strategy["action"] == "post_multipart_image":
return bc_post_multipart(f"/catalog/categories/{category_id}/image", strategy["value"])
return None
async function applyRepair(categoryId, strategy) {
if (strategy.action === "put_image_url") {
return bcPutJson("/catalog/categories", [{ id: categoryId, image_url: strategy.value }]);
}
if (strategy.action === "post_multipart_image") {
return bcPostMultipart(`/catalog/categories/${categoryId}/image`, strategy.value);
}
return null;
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the category_id, the chosen strategy (image_url versus multipart image_file), and the payload it would send. Read the output, agree with it, then switch it off. Any category flagged with no reachable URL and no local file is reported, never guessed at.
Always start with DRY_RUN=true, and never resend image_file to the JSON categories endpoint. Choosing the wrong endpoint or field again reproduces the exact 400 this fix exists to avoid.
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 always keeps image_url on the JSON path and image_file on the multipart path, never mixed.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Repair BigCommerce category images without resending image_file as JSON.
BigCommerce's V3 Catalog Categories JSON endpoint (PUT /v3/catalog/categories)
only accepts image_url for setting or replacing a category's image. image_file
is a real field, but it belongs to the separate multipart/form-data endpoint,
POST /v3/catalog/categories/{category_id}/image, which needs
Content-Type: multipart/form-data, not JSON. A sync script that PUTs image_file
as JSON to the categories endpoint gets a 400, "the field 'image_file' is
invalid", because that resource's schema has no such property. This job lists
categories, compares each one's image_url against a source-of-truth image
source, and repairs it with the correct call for whatever source is available:
image_url as JSON when a public URL exists, or image_file as multipart when
only a local file exists. A category with neither is flagged for manual
review, never guessed at. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/category-image-file-rejected/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_category_images")
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"
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_put_json(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def bc_post_multipart(path, file_path):
headers = {"X-Auth-Token": ACCESS_TOKEN, "Accept": "application/json"}
with open(file_path, "rb") as fh:
files = {"image_file": (os.path.basename(file_path), fh)}
r = requests.post(f"{API_BASE}{path}", headers=headers, files=files, timeout=60)
r.raise_for_status()
return r.json()
def choose_image_repair_strategy(category: dict, image_source: dict) -> dict:
"""Pure decision. No network, no side effects.
category = {"id": int, "image_url": str|None} the current BigCommerce state.
image_source = {"public_url": str|None, "local_file_path": str|None} what we
have on file for this category.
If image_url is missing, or differs from an available public_url, repair
with put_image_url (JSON, image_url field). Otherwise, if only a local file
exists, repair with post_multipart_image (multipart, image_file field,
scoped to this category's id). If neither source is available, flag for a
human. This function must never return action="put_image_url" paired with
field="image_file", that pairing is exactly the 400-triggering bug.
"""
current_url = category.get("image_url")
public_url = image_source.get("public_url")
local_file_path = image_source.get("local_file_path")
if public_url and (not current_url or current_url != public_url):
return {
"action": "put_image_url",
"endpoint": "/v3/catalog/categories",
"field": "image_url",
"value": public_url,
}
if local_file_path:
return {
"action": "post_multipart_image",
"endpoint": f"/v3/catalog/categories/{category['id']}/image",
"field": "image_file",
"value": local_file_path,
}
return {"action": "flag", "reason": "no_image_source_available"}
def all_categories():
page = 1
while True:
result = bc_get("/catalog/categories", {"limit": 250, "page": page})
for category in result.get("data", []):
yield category
pagination = result.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", page):
return
page += 1
def apply_repair(category_id, strategy):
if strategy["action"] == "put_image_url":
return bc_put_json("/catalog/categories", [{"id": category_id, "image_url": strategy["value"]}])
if strategy["action"] == "post_multipart_image":
return bc_post_multipart(f"/catalog/categories/{category_id}/image", strategy["value"])
return None
def load_image_source(category_id):
"""Placeholder for your source-of-truth lookup. Replace with a real
catalog/DB/CMS query that returns {"public_url": ..., "local_file_path": ...}
for the given category_id, using None for whichever is not available."""
return {"public_url": None, "local_file_path": None}
def run():
repaired = 0
flagged = 0
for category in all_categories():
category_id = category["id"]
image_source = load_image_source(category_id)
strategy = choose_image_repair_strategy(category, image_source)
if strategy["action"] == "flag":
log.warning(
"Category %s flagged. reason=%s current_image_url=%s",
category_id, strategy["reason"], category.get("image_url"),
)
flagged += 1
continue
log.info(
"category_id=%s action=%s endpoint=%s field=%s (%s)",
category_id, strategy["action"], strategy["endpoint"], strategy["field"],
"dry run" if DRY_RUN else "applying",
)
if not DRY_RUN:
apply_repair(category_id, strategy)
repaired += 1
log.info(
"Done. %d categor%s %s, %d categor%s flagged for review.",
repaired, "y" if repaired == 1 else "ies", "to repair" if DRY_RUN else "repaired",
flagged, "y" if flagged == 1 else "ies",
)
if __name__ == "__main__":
run()
/**
* Repair BigCommerce category images without resending image_file as JSON.
*
* BigCommerce's V3 Catalog Categories JSON endpoint (PUT /v3/catalog/categories)
* only accepts image_url for setting or replacing a category's image. image_file
* is a real field, but it belongs to the separate multipart/form-data endpoint,
* POST /v3/catalog/categories/{category_id}/image, which needs
* Content-Type: multipart/form-data, not JSON. A sync script that PUTs image_file
* as JSON to the categories endpoint gets a 400, "the field 'image_file' is
* invalid", because that resource's schema has no such property. This job lists
* categories, compares each one's image_url against a source-of-truth image
* source, and repairs it with the correct call for whatever source is available:
* image_url as JSON when a public URL exists, or image_file as multipart when
* only a local file exists. A category with neither is flagged for manual
* review, never guessed at. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/category-image-file-rejected/
*/
import { pathToFileURL } from "node:url";
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
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 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 side effects.
*
* category = { id, image_url } the current BigCommerce state.
* imageSource = { public_url, local_file_path } what we have on file.
*
* If image_url is missing, or differs from an available public_url, repair
* with put_image_url (JSON, image_url field). Otherwise, if only a local file
* exists, repair with post_multipart_image (multipart, image_file field,
* scoped to this category's id). If neither source is available, flag for a
* human. This function must never return action="put_image_url" paired with
* field="image_file", that pairing is exactly the 400-triggering bug.
*/
export function chooseImageRepairStrategy(category, imageSource) {
const currentUrl = category.image_url;
const publicUrl = imageSource.public_url;
const localFilePath = imageSource.local_file_path;
if (publicUrl && (!currentUrl || currentUrl !== publicUrl)) {
return {
action: "put_image_url",
endpoint: "/v3/catalog/categories",
field: "image_url",
value: publicUrl,
};
}
if (localFilePath) {
return {
action: "post_multipart_image",
endpoint: `/v3/catalog/categories/${category.id}/image`,
field: "image_file",
value: localFilePath,
};
}
return { action: "flag", reason: "no_image_source_available" };
}
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 bcPutJson(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPostMultipart(path, filePath) {
const bytes = await readFile(filePath);
const form = new FormData();
form.append("image_file", new Blob([bytes]), basename(filePath));
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "X-Auth-Token": ACCESS_TOKEN, Accept: "application/json" },
body: form,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* allCategories() {
let page = 1;
while (true) {
const result = await bcGet("/catalog/categories", { limit: 250, page });
for (const category of result.data || []) yield category;
const pagination = result.meta?.pagination || {};
if (page >= (pagination.total_pages || page)) return;
page += 1;
}
}
async function applyRepair(categoryId, strategy) {
if (strategy.action === "put_image_url") {
return bcPutJson("/catalog/categories", [{ id: categoryId, image_url: strategy.value }]);
}
if (strategy.action === "post_multipart_image") {
return bcPostMultipart(`/catalog/categories/${categoryId}/image`, strategy.value);
}
return null;
}
/** Placeholder for your source-of-truth lookup. Replace with a real
* catalog/DB/CMS query that returns { public_url, local_file_path } for the
* given categoryId, using null for whichever is not available. */
async function loadImageSource(categoryId) {
return { public_url: null, local_file_path: null };
}
export async function run() {
let repaired = 0;
let flagged = 0;
for await (const category of allCategories()) {
const categoryId = category.id;
const imageSource = await loadImageSource(categoryId);
const strategy = chooseImageRepairStrategy(category, imageSource);
if (strategy.action === "flag") {
console.warn(`Category ${categoryId} flagged. reason=${strategy.reason} current_image_url=${category.image_url}`);
flagged += 1;
continue;
}
console.log(
`category_id=${categoryId} action=${strategy.action} endpoint=${strategy.endpoint} ` +
`field=${strategy.field} (${DRY_RUN ? "dry run" : "applying"})`
);
if (!DRY_RUN) await applyRepair(categoryId, strategy);
repaired += 1;
}
console.log(
`Done. ${repaired} categor${repaired === 1 ? "y" : "ies"} ${DRY_RUN ? "to repair" : "repaired"}, ` +
`${flagged} categor${flagged === 1 ? "y" : "ies"} flagged for 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 which endpoint and which field name get used. Because choose_image_repair_strategy takes only plain values and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer, including the guardrail that it never pairs the JSON action with the image_file field.
from repair_category_images import choose_image_repair_strategy
def test_missing_image_url_with_public_url_uses_put_image_url():
category = {"id": 42, "image_url": None}
source = {"public_url": "https://cdn.example.com/cat-42.jpg", "local_file_path": None}
result = choose_image_repair_strategy(category, source)
assert result["action"] == "put_image_url"
assert result["field"] == "image_url"
assert result["value"] == "https://cdn.example.com/cat-42.jpg"
def test_stale_image_url_differing_from_source_uses_put_image_url():
category = {"id": 42, "image_url": "https://cdn.example.com/old.jpg"}
source = {"public_url": "https://cdn.example.com/new.jpg", "local_file_path": None}
result = choose_image_repair_strategy(category, source)
assert result["action"] == "put_image_url"
assert result["value"] == "https://cdn.example.com/new.jpg"
def test_no_public_url_with_local_file_uses_multipart_upload():
category = {"id": 42, "image_url": None}
source = {"public_url": None, "local_file_path": "/tmp/cat-42.jpg"}
result = choose_image_repair_strategy(category, source)
assert result["action"] == "post_multipart_image"
assert result["field"] == "image_file"
assert "/v3/catalog/categories/42/image" == result["endpoint"]
def test_no_source_at_all_flags_for_review():
category = {"id": 42, "image_url": None}
source = {"public_url": None, "local_file_path": None}
result = choose_image_repair_strategy(category, source)
assert result == {"action": "flag", "reason": "no_image_source_available"}
def test_matching_image_url_with_local_file_still_prefers_no_op_over_json_file_mix():
category = {"id": 42, "image_url": "https://cdn.example.com/same.jpg"}
source = {"public_url": "https://cdn.example.com/same.jpg", "local_file_path": "/tmp/cat-42.jpg"}
result = choose_image_repair_strategy(category, source)
assert result["action"] == "post_multipart_image"
def test_put_image_url_never_paired_with_image_file_field():
cases = [
({"id": 1, "image_url": None}, {"public_url": "https://cdn.example.com/a.jpg", "local_file_path": None}),
({"id": 2, "image_url": "https://cdn.example.com/old.jpg"}, {"public_url": "https://cdn.example.com/new.jpg", "local_file_path": "/tmp/a.jpg"}),
]
for category, source in cases:
result = choose_image_repair_strategy(category, source)
assert not (result["action"] == "put_image_url" and result.get("field") == "image_file")
import { test } from "node:test";
import assert from "node:assert/strict";
import { chooseImageRepairStrategy } from "./repair-category-images.js";
test("missing image_url with public_url uses put_image_url", () => {
const category = { id: 42, image_url: null };
const source = { public_url: "https://cdn.example.com/cat-42.jpg", local_file_path: null };
const result = chooseImageRepairStrategy(category, source);
assert.equal(result.action, "put_image_url");
assert.equal(result.field, "image_url");
assert.equal(result.value, "https://cdn.example.com/cat-42.jpg");
});
test("stale image_url differing from source uses put_image_url", () => {
const category = { id: 42, image_url: "https://cdn.example.com/old.jpg" };
const source = { public_url: "https://cdn.example.com/new.jpg", local_file_path: null };
const result = chooseImageRepairStrategy(category, source);
assert.equal(result.action, "put_image_url");
assert.equal(result.value, "https://cdn.example.com/new.jpg");
});
test("no public_url with local file uses multipart upload", () => {
const category = { id: 42, image_url: null };
const source = { public_url: null, local_file_path: "/tmp/cat-42.jpg" };
const result = chooseImageRepairStrategy(category, source);
assert.equal(result.action, "post_multipart_image");
assert.equal(result.field, "image_file");
assert.equal(result.endpoint, "/v3/catalog/categories/42/image");
});
test("no source at all flags for review", () => {
const category = { id: 42, image_url: null };
const source = { public_url: null, local_file_path: null };
const result = chooseImageRepairStrategy(category, source);
assert.deepEqual(result, { action: "flag", reason: "no_image_source_available" });
});
test("put_image_url is never paired with the image_file field", () => {
const cases = [
[{ id: 1, image_url: null }, { public_url: "https://cdn.example.com/a.jpg", local_file_path: null }],
[{ id: 2, image_url: "https://cdn.example.com/old.jpg" }, { public_url: "https://cdn.example.com/new.jpg", local_file_path: "/tmp/a.jpg" }],
];
for (const [category, source] of cases) {
const result = chooseImageRepairStrategy(category, source);
assert.ok(!(result.action === "put_image_url" && result.field === "image_file"));
}
});
Case studies
The migration script that carried over an old field name
A merchant migrating off an older platform wrote a category sync that mirrored their old PIM's field names field for field, including "image_file" for the category thumbnail. Every category update PUT to /v3/catalog/categories came back 400, and because the script bundled several fields into one payload, unrelated changes like the category name and sort order got rejected right along with the image.
Splitting the image handling into its own step, checking each category's image_url, then choosing image_url JSON or multipart image_file based on what source was available, let the rest of the payload go through cleanly again and the images finally updated on the first successful run.
The catalog with some categories on a CDN and others only as local exports
A catalog team had most category images hosted on a CDN with public URLs, but a batch of newer categories only had images sitting as exported files from a design tool, no public URL yet. A single hardcoded call, always image_url or always image_file, failed for whichever half of the catalog it did not match.
The pure decision function handled both without special casing per category: public URL present, PUT image_url; local file only, multipart image_file; neither, flag it. The categories with no image source at all turned out to be genuinely missing artwork, exactly what the flag was for.
After this runs, every category image update goes through the one endpoint that actually accepts its field: image_url as JSON when a public URL exists, image_file as multipart only when raw bytes are all you have. No more 400s for a field the JSON schema never supported, and no category gets an image guessed at when neither source is actually available. Those get flagged, and only those.
FAQ
Why does BigCommerce reject image_file when I PUT a category update?
The V3 Catalog Categories JSON endpoint (PUT /v3/catalog/categories) only accepts image_url for setting or replacing a category's image. image_file is a field on the separate multipart/form-data category image endpoint, POST /v3/catalog/categories/{category_id}/image, which requires Content-Type: multipart/form-data instead of JSON. Sending image_file in a JSON body gets a 400 because that resource's schema has no such property.
Which endpoint should I use to update a category image, image_url or image_file?
Use image_url on PUT /v3/catalog/categories as a JSON body when you have a public https URL for the image. Use image_file on POST /v3/catalog/categories/{category_id}/image as multipart/form-data only when you have the raw file bytes and no reachable URL. Never send image_file to the JSON categories endpoint.
Is it safe to auto-repair every category with a stale or missing image?
Only when there is an unambiguous image source. If a public_url is available, PUT image_url is safe and idempotent. If only a local file path is available, the multipart upload is used instead. A category with neither a reachable URL nor a local file is flagged for manual review rather than guessed at.
Related field notes
Citations
On the problem:
- bigcommerce-api-php GitHub Issue: updating category image_file field throws error. github.com bigcommerce-api-php issue #215
- BigCommerce Support Community: category image issue, has anyone else had this happen. support.bigcommerce.com category image issue
- BigCommerce Support Community: unable to update image via API V3. support.bigcommerce.com unable to update image via API V3
On the solution:
- BigCommerce Developer Center: Categories, the V3 Catalog REST reference. developer.bigcommerce.com categories
- BigCommerce Developer Center: Images, the category image_file and image_url endpoints. developer.bigcommerce.com category images
- BigCommerce Developer Center: Catalog overview. developer.bigcommerce.com catalog overview
Stuck on a tricky one?
If you have a problem in BigCommerce catalog, orders, payments, webhooks, or inventory 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 clear your category image 400s?
If this saved you a pile of failed syncs or caught the field mismatch you would have otherwise missed, 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