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.

Python and Node.js BigCommerce V3 Catalog Categories Safe by default (dry run)
Black and white music stand
Photo by Hc Digital on Unsplash
The short answer

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.

Sync script sends image_file PUT /v3/catalog /categories (JSON) schema: image_url only field unknown 400 response 'image_file' invalid Category image never updates
image_file is a real field, just on the wrong endpoint. The JSON categories schema only understands image_url, so the call 400s and the image stays stale.

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:

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 key insight

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.

List categories GET /v3/catalog/categories image_url missing or stale vs source? public_url available? yes PUT image_url JSON, categories no, local file POST image_file multipart, /image no source Flag for review
Two valid repairs, chosen by what image source actually exists. Never image_file on the JSON path, that pairing is exactly the bug.

Build it step by step

1

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.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// 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
2

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.

step2.py
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()
step2.js
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();
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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"}
decide.js
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" };
}
5

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.

apply.py
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
apply.js
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;
}
6

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.

Run it safe

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_category_images.py
"""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-category-images.js
/**
 * 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.

test_category_image_repair.py
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")
repair-category-images.test.js
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

Batch sync from a legacy PIM

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.

Mixed image sources

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.

What good looks like

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:

  1. bigcommerce-api-php GitHub Issue: updating category image_file field throws error. github.com bigcommerce-api-php issue #215
  2. BigCommerce Support Community: category image issue, has anyone else had this happen. support.bigcommerce.com category image issue
  3. BigCommerce Support Community: unable to update image via API V3. support.bigcommerce.com unable to update image via API V3

On the solution:

  1. BigCommerce Developer Center: Categories, the V3 Catalog REST reference. developer.bigcommerce.com categories
  2. BigCommerce Developer Center: Images, the category image_file and image_url endpoints. developer.bigcommerce.com category images
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes