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.jpgHere's what each step does, and why it's in that position:
resizewithfit: scale-downshrinks 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.removeBackgroundcuts out the product.background: "#ffffff"fills the now-transparent area with white. It has to come afterremoveBackground, because it only fills pixels that are transparent at that point.trimremoves 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
backgroundstep and useformat=png. - Smaller files for listing thumbnails: use
format=webp.qualityapplies to WebP too. - An off-white or brand color:
backgroundaccepts a hex code,rgb()/rgba(),hsl()/hsla(), or a named color, like"#f7f7f5"or"rgb(250, 248, 245)". Use commas insidergb(): 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
429handling below absorb any overshoot. - Retry only what's retryable.
429and5xxget a backoff and another try. Any4xxis a problem with that specific image, so log it and move on.402means 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:
| Pack | Price | Images | Per image |
|---|---|---|---|
| Pixel | €5 | 1,500 | €0.0033 |
| Canvas | €15 | 6,000 | €0.0025 |
| Studio | €50 | 25,000 | €0.0020 |
| Agency | €100 | 60,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
- Create an account and grab an API key.
- Read the API guide for the full error list and output options.
- Quickstarts in 8 languages.
