logobgbuster
Blog

Product photos at scale: white backgrounds and consistent sizing in one API call

How to turn supplier and studio photos into consistent white-background product images with one request per image, then run it across a whole catalog without hitting rate limits.

A product catalog usually starts inconsistent. Supplier photos come on grey sweeps, studio shots have slightly different lighting, and some images are 800px while others are 6000px. Before they go on a product page or a marketplace listing, they need to look like one set: a clean background, the same format, and a sensible size.

This guide builds that pipeline with bgbuster. First the single-image recipe, then a script that runs it across a whole catalog.

One request per image

Background removal alone gives you a transparent PNG. A product page usually wants more: a white background, a JPEG at a reasonable file size, and a bounded resolution. POST /images/edit does all of it in one request, applying the operations in order:

curl -X POST https://api.bgbuster.com/images/edit \
  -H "X-Api-Key: $BGBUSTER_API_KEY" \
  -F "input=@supplier-photo.jpg" \
  -F 'operations=[
    {"resize":{"width":2000,"height":2000,"fit":"scale-down"}},
    {"removeBackground":true},
    {"background":"#ffffff"},
    {"trim":true}
  ]' \
  -F "format=jpeg" \
  -F "quality=85" \
  -F "output=raw" \
  -o product.jpg

Here's what each step does, and why it's in that position:

  1. resize with fit: scale-down shrinks anything larger than 2000×2000 and leaves smaller images alone, so you never upscale a small supplier photo. Doing it first also means fewer pixels to segment.
  2. removeBackground cuts out the product.
  3. background: "#ffffff" fills the now-transparent area with white. It has to come after removeBackground, because it only fills pixels that are transparent at that point.
  4. trim removes the uniform white border, so every product is cropped tight and your storefront's CSS controls the spacing.

The whole request costs one credit.

Variations

  • Transparent PNG for banners and compositing: drop the background step and use format=png.
  • Smaller files for listing thumbnails: use format=webp. quality applies to WebP too.
  • An off-white or brand color: background accepts a hex code, rgb()/rgba(), hsl()/hsla(), or a named color, like "#f7f7f5" or "rgb(250, 248, 245)". Use commas inside rgb(): the space-separated form isn't supported.
  • Photos shot sideways: add {"rotate":90} (clockwise) before the other steps.

Marketplaces publish their own rules for main product images, such as background color, minimum size, and how much of the frame the product should fill. Check the current requirements for each channel you sell on. Your pipeline can output a different variant per channel from the same source photo.

Processing a whole catalog

A catalog run is the same request, repeated thousands of times. What matters is how you repeat it:

  • Cap concurrency. The API allows 400 requests per minute per IP address. How many requests you can keep in flight depends on how long each one takes: 10 concurrent requests that each take 2 seconds is 300 per minute. Start around 10, and let the 429 handling below absorb any overshoot.
  • Retry only what's retryable. 429 and 5xx get a backoff and another try. Any 4xx is a problem with that specific image, so log it and move on. 402 means you're out of credits, so stop the run.
  • Make it resumable. Write each result to disk (or your bucket) under a stable name, and skip images that already exist. If the run dies at image 4,000, restart it and it picks up where it left off.

Failed requests don't use credits, so retries only cost you time.

Node.js

Save as process-catalog.mjs. It reads a CSV with sku,image_url columns and writes out/<sku>.jpg:

import { readFile, writeFile, mkdir, access } from "node:fs/promises";

const API_KEY = process.env.BGBUSTER_API_KEY;
const CONCURRENCY = 10;
const OPERATIONS = JSON.stringify([
  { resize: { width: 2000, height: 2000, fit: "scale-down" } },
  { removeBackground: true },
  { background: "#ffffff" },
  { trim: true }
]);

const rows = (await readFile("catalog.csv", "utf8"))
  .trim()
  .split("\n")
  .slice(1) // skip the header row
  .map((line) => {
    const [sku, imageUrl] = line.split(",");
    return { sku, imageUrl };
  });

await mkdir("out", { recursive: true });

const exists = (path) => access(path).then(() => true, () => false);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function processRow({ sku, imageUrl }) {
  const target = `out/${sku}.jpg`;
  if (await exists(target)) return "skipped";

  for (let attempt = 1; attempt <= 4; attempt++) {
    const body = new FormData();
    body.append("input", imageUrl);
    body.append("operations", OPERATIONS);
    body.append("format", "jpeg");
    body.append("quality", "85");
    body.append("output", "raw");

    const response = await fetch("https://api.bgbuster.com/images/edit", {
      method: "POST",
      headers: { "X-Api-Key": API_KEY },
      body
    });

    if (response.ok) {
      await writeFile(target, Buffer.from(await response.arrayBuffer()));
      return "done";
    }

    if (response.status === 402) {
      throw new Error("Out of credits, stopping the run");
    }

    if (response.status !== 429 && response.status < 500) {
      console.error(`${sku}: ${response.status} ${await response.text()}`);
      return "failed";
    }

    await sleep(500 * 2 ** attempt);
  }

  console.error(`${sku}: gave up after 4 attempts`);
  return "failed";
}

// A fixed pool of workers pulling from a shared queue
const queue = [...rows];
const counts = { done: 0, skipped: 0, failed: 0 };

await Promise.all(
  Array.from({ length: CONCURRENCY }, async () => {
    for (let row = queue.shift(); row; row = queue.shift()) {
      counts[await processRow(row)]++;
    }
  })
);

console.log(counts);

Run it with BGBUSTER_API_KEY=... node process-catalog.mjs.

Python

The same approach with a thread pool:

import csv
import os
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import requests

API_KEY = os.environ["BGBUSTER_API_KEY"]
OPERATIONS = (
    '[{"resize":{"width":2000,"height":2000,"fit":"scale-down"}},'
    '{"removeBackground":true},{"background":"#ffffff"},{"trim":true}]'
)
OUT = Path("out")
OUT.mkdir(exist_ok=True)


class OutOfCredits(Exception):
    pass


def process_row(row):
    target = OUT / f"{row['sku']}.jpg"
    if target.exists():
        return "skipped"

    for attempt in range(1, 5):
        response = requests.post(
            "https://api.bgbuster.com/images/edit",
            headers={"X-Api-Key": API_KEY},
            data={
                "input": row["image_url"],
                "operations": OPERATIONS,
                "format": "jpeg",
                "quality": "85",
                "output": "raw",
            },
            timeout=120,
        )

        if response.ok:
            target.write_bytes(response.content)
            return "done"
        if response.status_code == 402:
            raise OutOfCredits()
        if response.status_code != 429 and response.status_code < 500:
            print(f"{row['sku']}: {response.status_code} {response.text}")
            return "failed"

        time.sleep(0.5 * 2**attempt)

    return "failed"


with open("catalog.csv", newline="") as f:
    rows = list(csv.DictReader(f))

with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(process_row, rows))

print({status: results.count(status) for status in set(results)})

If the product photos live on a CDN that blocks automated requests (403 IMAGE_FORBIDDEN), download each image first and send it as a file instead of a URL.

What a catalog run costs

One credit per processed image. Credits come in packs, never expire, and have no subscription attached:

PackPriceImagesPer image
Pixel€51,500€0.0033
Canvas€156,000€0.0025
Studio€5025,000€0.0020
Agency€10060,000€0.0017

A 5,000-SKU catalog with three photos per product is 15,000 images. The cheapest way to cover it is two Canvas packs plus two Pixel packs (€40). One Studio pack (€50) costs a bit more but leaves 10,000 credits for new products later. See the pricing breakdown for how that compares to subscription-based APIs.

Before you run the whole catalog

  • Pilot a sample. Pick 50 images that cover your hardest categories, such as jewelry, glassware, fur, and white products on white sweeps, and review them before processing everything.
  • Keep the originals. Write results to a separate location. If you change the recipe later, you can rerun from the source photos.
  • Review, don't just publish. The API handles most product photos well. Complex edges and transparent materials still deserve a human check before they go live.

Next steps

Try the bgbuster API

Pay per image, no subscription, credits that never expire.

See pricing