Reconciler Catalog / Products

BigCommerce bulk image API only persists the first image per request

The import script uploads five images for a product and logs five 200s, but the product page in the admin only ever shows one. There is no bug in your loop. BigCommerce's v3 catalog images endpoint was never built to take a batch, it takes exactly one image per call, and a script written by analogy to the batch products or variants endpoints will quietly lose every image after the first. Here is why that gap opens up and a small reconciler that finds the missing images per product and requeues only those.

Python and Node.js BigCommerce V3 Catalog Images API Safe by default (dry run)
Camera studio set up
Photo by Alexander Dummer on Unsplash
The short answer

BigCommerce's v3 Catalog API has no batch endpoint for product images. POST /v3/catalog/products/{product_id}/images is scoped to create exactly one image resource per call, taking a single image_file (multipart/form-data) or a single image_url (application/json), unlike the batch endpoints that exist for products (PUT /v3/catalog/products) and variants (PUT /v3/catalog/products/{product_id}/variants). A script that sends an array, expecting the same batching behavior, either gets a 422 on the unexpected shape or only has its first element serialized, so the rest are dropped silently behind a response that still looks like success. Run a small Python or Node.js script that reads GET /v3/catalog/products/{product_id}/images for each product, diffs the persisted image list against your source manifest, and issues one POST .../images call per missing image, never batched, gated behind a dry run flag.

The problem in plain words

Two other v3 catalog endpoints train you to expect batching. PUT /v3/catalog/products takes an array of products and updates all of them in one call. PUT /v3/catalog/products/{product_id}/variants does the same for variants. And when you read a product with GET /v3/catalog/products/{id}?include=images, the response hands back a nested images array sitting right there on the product object. It is a completely reasonable guess, from those two facts, that the images endpoint accepts an array too.

It does not. POST /v3/catalog/products/{product_id}/images only ever creates one image resource per call. Send it a JSON body with image_url set to a single string, and it works. Send it an array of URLs, or a list under some made-up key, and one of two things happens: BigCommerce responds with a 422 because the payload shape does not match the schema, or your own client or serializer only encodes the first element of the array and posts that, discarding the rest before the request even leaves your machine. Either way, the loop keeps going, the response for that one request looks like a normal 200 or 201, and nothing in the log tells you four out of five images never made the trip.

Import script sends 5 image URLs POST .../images one image per call only no batch endpoint exists 4 of 5 dropped 200 response looks successful 1 image saved 4 images missing
The endpoint was never built for batching. It accepts the request, persists exactly one image, and gives no signal that the rest of the array never landed.

Why it happens

The images endpoint's single-image scope is documented, but nothing about the surrounding API discourages the wrong assumption. A few common ways stores end up with products missing most of their images:

This is a recurring theme in BigCommerce's own support threads on bulk image uploads and multi-image imports: the v3 Catalog API simply does not expose a batch operation for images the way it does for products and variants. See the citations at the end for the exact threads and the API reference.

The key insight

The number of 200 responses your import logged is not proof of how many images landed. The product's own images list is. So the safe pattern is not "resend the whole batch and hope." It is "read GET /v3/catalog/products/{product_id}/images for the product, compare it against the source manifest's image list, and requeue only the images whose key is missing." We match on a normalized key, basename or canonical URL, because the source filename and the CDN URL BigCommerce returns will never be identical strings, and one image per requeued POST, because that is the only shape the endpoint accepts.

The fix, as a flow

We do not touch the working import path. We add a reconciler that, per product, lists what actually persisted, diffs it against what the source manifest says should be there, and posts exactly the missing images back in, one request per image, in the source's original sort order.

For each product in the source manifest GET .../images list persisted images Diff vs manifest normalize URL/filename Any images missing? yes no, reconciled POST one image per missing image
The reconciler only ever posts one image per call, matching the endpoint's real shape, and only for the images the diff confirms are missing.

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 create catalog 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 SOURCE_MANIFEST_PATH="./import-manifest.json"
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 SOURCE_MANIFEST_PATH="./import-manifest.json"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V3 Catalog API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. V3 wraps list responses in {data, meta.pagination}. A small helper handles GET and POST and raises on a non-2xx response. We reuse it to list persisted images and to post each missing one.

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_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List the persisted images for each product

Call GET /v3/catalog/products/{product_id}/images, paginated via meta.pagination, to get every image BigCommerce actually has on record for that product. Capture data[].image_url, data[].is_thumbnail, and data[].sort_order from each entry, since the diff needs those to match against the source manifest and to pick the next sort_order for anything requeued.

step3.py
def persisted_images(product_id):
    images = []
    page = 1
    while True:
        resp = bc_get(f"/catalog/products/{product_id}/images", {"page": page, "limit": 250})
        batch = resp.get("data", [])
        if not batch:
            return images
        images.extend(batch)
        pagination = resp.get("meta", {}).get("pagination", {})
        if page >= pagination.get("total_pages", page):
            return images
        page += 1
step3.js
async function persistedImages(productId) {
  const images = [];
  let page = 1;
  while (true) {
    const resp = await bcGet(`/catalog/products/${productId}/images`, { page, limit: 250 });
    const batch = resp.data || [];
    if (!batch.length) return images;
    images.push(...batch);
    const pagination = resp.meta?.pagination || {};
    if (page >= (pagination.total_pages || page)) return images;
    page += 1;
  }
}
4

Diff the source manifest against what actually persisted, with one pure function

Keep the decision in its own function that takes the product's ordered source image list and the data array from the images GET, and returns exactly the source images that never made it in, in source order. Normalize both sides to a comparable key, the basename or a canonical URL, since the CDN URL BigCommerce returns will never match your source URL string for string.

diff.py
from urllib.parse import urlparse, unquote
import posixpath

def _normalize_key(url_or_name: str) -> str:
    if not url_or_name:
        return ""
    path = urlparse(url_or_name).path or url_or_name
    return unquote(posixpath.basename(path)).strip().lower()

def diff_missing_images(source_images: list, persisted_images: list) -> list:
    persisted_keys = {
        _normalize_key(img.get("image_url", ""))
        for img in persisted_images
        if img.get("image_url")
    }
    return [
        src for src in source_images
        if _normalize_key(src) not in persisted_keys
    ]
diff.js
function normalizeKey(urlOrName) {
  if (!urlOrName) return "";
  let path = urlOrName;
  try {
    path = new URL(urlOrName).pathname;
  } catch {
    path = urlOrName;
  }
  const basename = path.split("/").filter(Boolean).pop() || path;
  return decodeURIComponent(basename).trim().toLowerCase();
}

export function diffMissingImages(sourceImages, persistedImages) {
  const persistedKeys = new Set(
    (persistedImages || [])
      .map((img) => img.image_url)
      .filter(Boolean)
      .map(normalizeKey)
  );
  return (sourceImages || []).filter((src) => !persistedKeys.has(normalizeKey(src)));
}
5

Requeue the missing images, one POST per image

For each missing source image, call POST /v3/catalog/products/{product_id}/images with {"image_url": "<source_url>", "is_thumbnail": false, "sort_order": <n>} and Content-Type: application/json. Never send an array. If you are uploading raw bytes instead of a hosted URL, send multipart image_file instead, still one call per image. Assign sort_order starting after the highest sort_order already persisted, so requeued images land after the ones that already made it in.

apply.py
def next_sort_order(persisted_images: list) -> int:
    if not persisted_images:
        return 0
    return max((img.get("sort_order", 0) for img in persisted_images), default=-1) + 1

def upload_one_image(product_id, image_url, sort_order):
    return bc_post(
        f"/catalog/products/{product_id}/images",
        {"image_url": image_url, "is_thumbnail": False, "sort_order": sort_order},
    )
apply.js
function nextSortOrder(persistedImages) {
  if (!persistedImages || !persistedImages.length) return 0;
  return Math.max(...persistedImages.map((img) => img.sort_order ?? 0)) + 1;
}

async function uploadOneImage(productId, imageUrl, sortOrder) {
  return bcPost(`/catalog/products/${productId}/images`, {
    image_url: imageUrl,
    is_thumbnail: false,
    sort_order: sortOrder,
  });
}
6

Wire it together with a dry run guard, and re-verify after each batch

The loop ties every piece together: list persisted images, diff against the source manifest, requeue what is missing one call at a time. On the first few runs, leave DRY_RUN on so the script only logs the {product_id, image_url, sort_order} tuple it would post. Read the output, agree with it, then switch it off. After a requeue batch, re-run the detection GET and confirm len(data) == len(source_images) before marking a product reconciled; you can cross-check with GET /v3/catalog/products/{product_id}?include=images to rule out a pagination artifact.

Run it safe

Always start with DRY_RUN=true, and never send an array to POST /v3/catalog/products/{product_id}/images. The endpoint has no batch mode, so one call per image is not an optimization, it is the only shape that persists.

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 is safe to run again and again because it only requeues images the diff confirms are missing, one POST per image, never a batch.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

requeue_missing_images.py
"""Requeue BigCommerce product images dropped by a batch-shaped import.

BigCommerce's v3 Catalog API has no batch endpoint for product images.
POST /v3/catalog/products/{product_id}/images is scoped to create exactly one
image resource per call, a single image_file (multipart/form-data) or a single
image_url (application/json), unlike the batch endpoints that exist for
products (PUT /v3/catalog/products) and variants
(PUT /v3/catalog/products/{product_id}/variants). Import scripts written by
analogy to those batch endpoints, or to the nested images array returned by
GET .../products?include=images, assume the images endpoint also accepts an
array. BigCommerce either 422s on the unexpected shape or the client's
serializer only encodes the first element, so every image after the first is
dropped behind a response that still looks like success. This job reads the
persisted images for each product, diffs them against a source manifest, and
requeues only the images that are actually missing, one POST per image. Safe
to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/bulk-image-api-single-image-per-request/
"""
import json
import logging
import os
import posixpath
from urllib.parse import unquote, urlparse

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("requeue_missing_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"
SOURCE_MANIFEST_PATH = os.environ.get("SOURCE_MANIFEST_PATH", "./import-manifest.json")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()


def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def _normalize_key(url_or_name: str) -> str:
    """Reduce a source filename or a BigCommerce CDN URL to a comparable key."""
    if not url_or_name:
        return ""
    path = urlparse(url_or_name).path or url_or_name
    return unquote(posixpath.basename(path)).strip().lower()


def diff_missing_images(source_images: list, persisted_images: list) -> list:
    """Pure function. No network, no side effects.

    source_images: ordered list of source image URLs/filenames for one product.
    persisted_images: the `data` array from GET .../products/{id}/images, each
    dict with at least `image_url`, `id`, and `sort_order`.

    Returns the sublist of source_images whose normalized key (basename or
    canonical URL) is not present among the persisted images' normalized keys,
    preserving source order, so the caller knows exactly which images to
    requeue and in what order.
    """
    persisted_keys = {
        _normalize_key(img.get("image_url", ""))
        for img in (persisted_images or [])
        if img.get("image_url")
    }
    return [
        src for src in (source_images or [])
        if _normalize_key(src) not in persisted_keys
    ]


def next_sort_order(persisted_images: list) -> int:
    if not persisted_images:
        return 0
    return max((img.get("sort_order", 0) for img in persisted_images), default=-1) + 1


def persisted_images(product_id):
    """Page through GET /v3/catalog/products/{product_id}/images."""
    images = []
    page = 1
    while True:
        resp = bc_get(f"/catalog/products/{product_id}/images", {"page": page, "limit": 250})
        batch = resp.get("data", [])
        if not batch:
            return images
        images.extend(batch)
        pagination = resp.get("meta", {}).get("pagination", {})
        if page >= pagination.get("total_pages", page):
            return images
        page += 1


def upload_one_image(product_id, image_url, sort_order):
    """One image per call. The endpoint has no batch mode."""
    return bc_post(
        f"/catalog/products/{product_id}/images",
        {"image_url": image_url, "is_thumbnail": False, "sort_order": sort_order},
    )


def load_source_manifest(path):
    """Expected shape: {"products": [{"product_id": 123, "images": ["https://.../a.jpg", ...]}, ...]}"""
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def run():
    manifest = load_source_manifest(SOURCE_MANIFEST_PATH)
    reconciled = 0
    requeued_total = 0

    for product in manifest.get("products", []):
        product_id = product["product_id"]
        source_images = product.get("images", [])
        if not source_images:
            continue

        current = persisted_images(product_id)
        missing = diff_missing_images(source_images, current)

        if not missing:
            reconciled += 1
            continue

        sort_order = next_sort_order(current)
        for image_url in missing:
            log.info(
                "product_id=%s image_url=%s sort_order=%s (%s)",
                product_id, image_url, sort_order,
                "dry run" if DRY_RUN else "uploading",
            )
            if not DRY_RUN:
                upload_one_image(product_id, image_url, sort_order)
            sort_order += 1
            requeued_total += 1

        if not DRY_RUN:
            after = persisted_images(product_id)
            still_missing = diff_missing_images(source_images, after)
            if still_missing:
                log.warning(
                    "product_id=%s still missing %d image(s) after requeue: %s",
                    product_id, len(still_missing), still_missing,
                )
            else:
                reconciled += 1
                log.info("product_id=%s reconciled, %d image(s) now persisted", product_id, len(after))

    log.info(
        "Done. %d image(s) %s, %d product(s) reconciled.",
        requeued_total, "to requeue" if DRY_RUN else "requeued", reconciled,
    )


if __name__ == "__main__":
    run()
requeue-missing-images.js
/**
 * Requeue BigCommerce product images dropped by a batch-shaped import.
 *
 * BigCommerce's v3 Catalog API has no batch endpoint for product images.
 * POST /v3/catalog/products/{product_id}/images is scoped to create exactly
 * one image resource per call, a single image_file (multipart/form-data) or a
 * single image_url (application/json), unlike the batch endpoints that exist
 * for products (PUT /v3/catalog/products) and variants
 * (PUT /v3/catalog/products/{product_id}/variants). Import scripts written by
 * analogy to those batch endpoints, or to the nested images array returned by
 * GET .../products?include=images, assume the images endpoint also accepts an
 * array. BigCommerce either 422s on the unexpected shape or the client's
 * serializer only encodes the first element, so every image after the first
 * is dropped behind a response that still looks like success. This job reads
 * the persisted images for each product, diffs them against a source
 * manifest, and requeues only the images that are actually missing, one POST
 * per image. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/bulk-image-api-single-image-per-request/
 */
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const SOURCE_MANIFEST_PATH = process.env.SOURCE_MANIFEST_PATH || "./import-manifest.json";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/** Reduce a source filename or a BigCommerce CDN URL to a comparable key. */
function normalizeKey(urlOrName) {
  if (!urlOrName) return "";
  let path = urlOrName;
  try {
    path = new URL(urlOrName).pathname;
  } catch {
    path = urlOrName;
  }
  const basename = path.split("/").filter(Boolean).pop() || path;
  return decodeURIComponent(basename).trim().toLowerCase();
}

/**
 * Pure function. No network, no side effects.
 *
 * sourceImages: ordered list of source image URLs/filenames for one product.
 * persistedImages: the `data` array from GET .../products/{id}/images, each
 * object with at least `image_url`, `id`, and `sort_order`.
 *
 * Returns the sublist of sourceImages whose normalized key (basename or
 * canonical URL) is not present among the persisted images' normalized keys,
 * preserving source order, so the caller knows exactly which images to
 * requeue and in what order.
 */
export function diffMissingImages(sourceImages, persistedImages) {
  const persistedKeys = new Set(
    (persistedImages || [])
      .map((img) => img.image_url)
      .filter(Boolean)
      .map(normalizeKey)
  );
  return (sourceImages || []).filter((src) => !persistedKeys.has(normalizeKey(src)));
}

export function nextSortOrder(persistedImages) {
  if (!persistedImages || !persistedImages.length) return 0;
  return Math.max(...persistedImages.map((img) => img.sort_order ?? 0)) + 1;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function persistedImages(productId) {
  const images = [];
  let page = 1;
  while (true) {
    const resp = await bcGet(`/catalog/products/${productId}/images`, { page, limit: 250 });
    const batch = resp.data || [];
    if (!batch.length) return images;
    images.push(...batch);
    const pagination = resp.meta?.pagination || {};
    if (page >= (pagination.total_pages || page)) return images;
    page += 1;
  }
}

/** One image per call. The endpoint has no batch mode. */
async function uploadOneImage(productId, imageUrl, sortOrder) {
  return bcPost(`/catalog/products/${productId}/images`, {
    image_url: imageUrl,
    is_thumbnail: false,
    sort_order: sortOrder,
  });
}

/** Expected shape: {"products": [{"product_id": 123, "images": ["https://.../a.jpg", ...]}, ...]} */
function loadSourceManifest(path) {
  return JSON.parse(readFileSync(path, "utf-8"));
}

export async function run() {
  const manifest = loadSourceManifest(SOURCE_MANIFEST_PATH);
  let reconciled = 0;
  let requeuedTotal = 0;

  for (const product of manifest.products || []) {
    const productId = product.product_id;
    const sourceImages = product.images || [];
    if (!sourceImages.length) continue;

    const current = await persistedImages(productId);
    const missing = diffMissingImages(sourceImages, current);

    if (!missing.length) {
      reconciled += 1;
      continue;
    }

    let sortOrder = nextSortOrder(current);
    for (const imageUrl of missing) {
      console.log(
        `product_id=${productId} image_url=${imageUrl} sort_order=${sortOrder} ` +
        `(${DRY_RUN ? "dry run" : "uploading"})`
      );
      if (!DRY_RUN) await uploadOneImage(productId, imageUrl, sortOrder);
      sortOrder += 1;
      requeuedTotal += 1;
    }

    if (!DRY_RUN) {
      const after = await persistedImages(productId);
      const stillMissing = diffMissingImages(sourceImages, after);
      if (stillMissing.length) {
        console.warn(
          `product_id=${productId} still missing ${stillMissing.length} image(s) after requeue: ${stillMissing}`
        );
      } else {
        reconciled += 1;
        console.log(`product_id=${productId} reconciled, ${after.length} image(s) now persisted`);
      }
    }
  }

  console.log(
    `Done. ${requeuedTotal} image(s) ${DRY_RUN ? "to requeue" : "requeued"}, ${reconciled} product(s) reconciled.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The diff is the part most worth testing, because it decides exactly which images get requeued. Because diff_missing_images takes only plain values and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain lists and dicts and checks the answer.

test_bulk_image_diff.py
from requeue_missing_images import diff_missing_images, next_sort_order


def persisted(image_url, sort_order=0, id_=1):
    return {"id": id_, "image_url": image_url, "is_thumbnail": False, "sort_order": sort_order}


def test_no_missing_images_when_everything_persisted():
    source = ["https://cdn.example.com/imports/a.jpg", "https://cdn.example.com/imports/b.jpg"]
    persisted_images = [
        persisted("https://cdn.bigcommerce.com/store/products/1/a.jpg", 0),
        persisted("https://cdn.bigcommerce.com/store/products/1/b.jpg", 1),
    ]
    assert diff_missing_images(source, persisted_images) == []


def test_only_first_image_persisted_reports_the_rest_missing():
    source = [
        "https://cdn.example.com/imports/a.jpg",
        "https://cdn.example.com/imports/b.jpg",
        "https://cdn.example.com/imports/c.jpg",
    ]
    persisted_images = [persisted("https://cdn.bigcommerce.com/store/products/1/a.jpg", 0)]
    assert diff_missing_images(source, persisted_images) == [
        "https://cdn.example.com/imports/b.jpg",
        "https://cdn.example.com/imports/c.jpg",
    ]


def test_matching_is_by_normalized_filename_not_exact_url():
    source = ["https://cdn.example.com/imports/A.JPG%20"]
    persisted_images = [persisted("https://cdn.bigcommerce.com/store/products/1/a.jpg", 0)]
    assert diff_missing_images(source, persisted_images) == []


def test_no_persisted_images_means_everything_is_missing():
    source = ["https://cdn.example.com/imports/a.jpg", "https://cdn.example.com/imports/b.jpg"]
    assert diff_missing_images(source, []) == source


def test_preserves_source_order_for_requeuing():
    source = ["https://cdn.example.com/imports/z.jpg", "https://cdn.example.com/imports/a.jpg"]
    assert diff_missing_images(source, []) == source


def test_next_sort_order_continues_after_highest_existing():
    persisted_images = [persisted("a.jpg", 0), persisted("b.jpg", 3)]
    assert next_sort_order(persisted_images) == 4


def test_next_sort_order_starts_at_zero_when_no_images_persisted():
    assert next_sort_order([]) == 0
requeue-missing-images.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffMissingImages, nextSortOrder } from "./requeue-missing-images.js";

const persisted = (imageUrl, sortOrder = 0, id = 1) => ({
  id, image_url: imageUrl, is_thumbnail: false, sort_order: sortOrder,
});

test("no missing images when everything persisted", () => {
  const source = ["https://cdn.example.com/imports/a.jpg", "https://cdn.example.com/imports/b.jpg"];
  const persistedImages = [
    persisted("https://cdn.bigcommerce.com/store/products/1/a.jpg", 0),
    persisted("https://cdn.bigcommerce.com/store/products/1/b.jpg", 1),
  ];
  assert.deepEqual(diffMissingImages(source, persistedImages), []);
});

test("only first image persisted reports the rest missing", () => {
  const source = [
    "https://cdn.example.com/imports/a.jpg",
    "https://cdn.example.com/imports/b.jpg",
    "https://cdn.example.com/imports/c.jpg",
  ];
  const persistedImages = [persisted("https://cdn.bigcommerce.com/store/products/1/a.jpg", 0)];
  assert.deepEqual(diffMissingImages(source, persistedImages), [
    "https://cdn.example.com/imports/b.jpg",
    "https://cdn.example.com/imports/c.jpg",
  ]);
});

test("matching is by normalized filename not exact url", () => {
  const source = ["https://cdn.example.com/imports/A.JPG%20"];
  const persistedImages = [persisted("https://cdn.bigcommerce.com/store/products/1/a.jpg", 0)];
  assert.deepEqual(diffMissingImages(source, persistedImages), []);
});

test("no persisted images means everything is missing", () => {
  const source = ["https://cdn.example.com/imports/a.jpg", "https://cdn.example.com/imports/b.jpg"];
  assert.deepEqual(diffMissingImages(source, []), source);
});

test("preserves source order for requeuing", () => {
  const source = ["https://cdn.example.com/imports/z.jpg", "https://cdn.example.com/imports/a.jpg"];
  assert.deepEqual(diffMissingImages(source, []), source);
});

test("next sort order continues after highest existing", () => {
  const persistedImages = [persisted("a.jpg", 0), persisted("b.jpg", 3)];
  assert.equal(nextSortOrder(persistedImages), 4);
});

test("next sort order starts at zero when no images persisted", () => {
  assert.equal(nextSortOrder([]), 0);
});

Case studies

PIM migration

The migration that thought images batched like products

A team moving a few thousand SKUs off a legacy platform wrote their importer against PUT /v3/catalog/products first, then reused the same batching pattern for images because it worked so cleanly for the product fields. Every product ended up with exactly one image, always the first one in the source list, and nobody noticed until a merchandiser spotted bare product pages weeks later.

The reconciler read every product's persisted images, diffed them against the original PIM export's image lists, and found the same pattern on nearly every SKU: one image in, the rest silently gone. A single requeue pass, one POST per missing image, closed the gap without re-touching the images that had already landed correctly.

Multipart client bug

The uploader whose form library only kept the first file

A custom uploader accepted a folder of product photos and built one multipart request per product, attaching every file under the same image_file field name. The HTTP client silently collapsed repeated field names down to the last, or in some cases the first, value, so most products kept just a single photo no matter how many files were dropped in the folder.

Because the reconciler works off the images actually persisted rather than trusting the uploader's own success log, it caught this the same way as any other cause: read, diff, requeue one file per call. The fix to the uploader itself was separate, switching to one request per file, but the backlog of already-broken products needed exactly this script to catch up.

What good looks like

After this runs, every product's persisted image count matches its source manifest, whether the original gap came from a batch-shaped import, a multipart client bug, or a manual CSV upload. The script never re-uploads an image that already landed, because the diff is keyed on what BigCommerce actually has, not on what your import log claims it sent. Run it once to backfill, then again after any bulk import to confirm nothing quietly repeated the original mistake.

FAQ

Why does only the first image in my batch actually get saved on the product?

BigCommerce's v3 catalog has no batch endpoint for product images. POST /v3/catalog/products/{product_id}/images is scoped to create exactly one image resource per call, either a single image_file (multipart/form-data) or a single image_url (application/json). Import scripts written by analogy to the batch products or variants endpoints, or to the nested images array returned by GET .../products?include=images, assume the images endpoint also takes an array. BigCommerce either rejects the unexpected shape or the client only serializes the first element, so every image after the first is dropped without a clear error.

Is there a way to upload multiple product images in one API call?

No. As of the current v3 Catalog API, there is no bulk or batch endpoint for product images, unlike PUT /v3/catalog/products for products and PUT /v3/catalog/products/{product_id}/variants for variants. Every image needs its own POST /v3/catalog/products/{product_id}/images call, one image per request, whether you send image_url as JSON or image_file as multipart form data.

How do I find out which products are missing images without re-uploading everything?

Call GET /v3/catalog/products/{product_id}/images, paginate through meta.pagination, and collect the image_url, is_thumbnail, and sort_order of every returned image. Compare that persisted list against your source manifest's image list for the same product using a normalized key such as the filename or canonical URL. Only the source images whose key is missing from the persisted set need to be requeued, so you never re-upload images that already made it in.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: importing multiple product images (v3). bigcommerce.my.site.com importing multiple product images (v3)
  2. BigCommerce Support: uploading pictures to products in bulk. support.bigcommerce.com uploading pictures to products in bulk
  3. BigCommerce Support: bulk import of products and images. support.bigcommerce.com bulk import of products and images

On the solution:

  1. BigCommerce Developer Center: Product Images API reference. developer.bigcommerce.com images
  2. BigCommerce API Reference: List Product Images. docs.bigcommerce.com get product images
  3. BigCommerce Developer Center: V2 to V3 Catalog Operations Comparison. developer.bigcommerce.com V2 to V3 catalog migration guide

Stuck on a tricky one?

If you have a problem in BigCommerce catalog, products, orders, 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 recover your missing images?

If this saved you a pile of manual re-uploads or caught products 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