Removing the background from ten images is easy. Removing it from 40,000 catalog photos overnight takes more planning: you need concurrency, retries, rate-limit handling, and a way to resume when something stops halfway. This guide walks through a bulk background removal script you can run today.

Plan the job before you start

  • Volume: count the images and pick a plan that covers them. The Pro plan includes 7,500 requests per month, Ultra includes 52,000, and Mega includes 255,000.
  • Throughput: each plan has a per-minute rate limit (100 to 210 requests per minute on monthly plans). At 150 requests per minute, 10,000 images take a little over an hour.
  • Storage: transparent PNGs are usually larger than the JPEG originals. Make sure the output disk or bucket has room.

A resumable bulk script in Python

This script uses a small thread pool, skips files that were already processed, and honors the Retry-After header when the API returns 429 Too Many Requests.

import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import requests

API_URL = "https://fapihub.com/v2/rembg/"
API_KEY = "YOUR_API_KEY"
INPUT_DIR = Path("input")
OUTPUT_DIR = Path("output")
WORKERS = 4
EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}

session = requests.Session()
session.headers["ApiKey"] = API_KEY


def remove_background(path: Path) -> str:
    target = OUTPUT_DIR / f"{path.stem}.png"
    if target.exists():
        return f"skipped {path.name}"

    for attempt in range(5):
        with path.open("rb") as image_file:
            response = session.post(API_URL, files={"image": image_file}, timeout=60)

        if response.status_code == 200:
            target.write_bytes(response.content)
            return f"done {path.name}"
        if response.status_code == 429 or response.status_code >= 500:
            wait = int(response.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue
        return f"failed {path.name}: {response.status_code} {response.text[:200]}"

    return f"failed {path.name}: retries exhausted"


OUTPUT_DIR.mkdir(exist_ok=True)
images = [p for p in INPUT_DIR.iterdir() if p.suffix.lower() in EXTENSIONS]

with ThreadPoolExecutor(max_workers=WORKERS) as pool:
    futures = [pool.submit(remove_background, path) for path in images]
    for future in as_completed(futures):
        print(future.result())

Why these details matter

Bounded concurrency

Firing every request at once does not make the job faster. It just turns into a wall of 429 responses. A handful of workers keeps the pipeline full while staying under the per-minute limit. Raise WORKERS gradually and watch the response codes.

Skip finished files

Checking for an existing output file makes the script safe to re-run. If your laptop sleeps or the network drops at image 6,214, run it again and it continues where it stopped, without spending quota on images it already finished.

Retry only what is retryable

Rate limits and 5xx server errors are temporary, so the script waits and tries again. A 4xx error like an unsupported file or an invalid key will not fix itself, so the script records it and moves on. On FAPIhub, 5xx errors do not count against your quota.

Check the input folder first

A preflight pass catches problems before they turn into failed requests. The API accepts JPEG, PNG, WebP, BMP, and GIF files up to 10 MB each, and 4xx errors such as an invalid image still count against your quota. Listing bad files up front is cheaper than discovering them halfway through.

import hashlib
from pathlib import Path

ACCEPTED = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
MAX_BYTES = 10 * 1024 * 1024

seen = {}
for path in sorted(Path("input").iterdir()):
    if path.suffix.lower() not in ACCEPTED:
        print(f"unsupported format: {path.name}")
        continue
    if path.stat().st_size > MAX_BYTES:
        print(f"over 10 MB, resize first: {path.name}")
        continue
    digest = hashlib.sha256(path.read_bytes()).hexdigest()
    if digest in seen:
        print(f"duplicate of {seen[digest]}: {path.name}")
    seen[digest] = path.name

Duplicates are common in catalog exports, where the same photo is attached to several product variants. Process one copy and reuse the output for the others.

Stop early on account-level errors

Most 4xx errors belong to a single file, so recording them and moving on is correct. Two do not. A 401 invalid_api_key or a 402 insufficient_credits response means every remaining image will fail the same way. Raise an exception for those so the whole job stops instead of logging thousands of identical failures.

class StopJob(Exception):
    pass


# inside remove_background(), before the generic failure return:
if response.status_code in (401, 402):
    error = response.json().get("error", {})
    raise StopJob(f"{error.get('code')}: {error.get('message')}")


# and in the main loop, cancel queued work when it happens:
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
    futures = [pool.submit(remove_background, path) for path in images]
    try:
        for future in as_completed(futures):
            print(future.result())
    except StopJob as error:
        pool.shutdown(cancel_futures=True)  # Python 3.9+
        print(f"stopped: {error}")

Without cancel_futures=True, the executor would keep working through every queued image before exiting. Once the job stops, fix the cause (check the key in your dashboard or upgrade the plan) and re-run. The skip-finished-files check means nothing already completed is sent again.

Keep a manifest

Printed output scrolls away. For large jobs, write one line per image to a JSON Lines file: the input name, the status, and for failures the error code and request_id from the JSON error body. The manifest tells you exactly which files need attention, and the request_id lets support trace a specific request if you need help.

import json
import threading

manifest_lock = threading.Lock()


def record(name, status, code=None, request_id=None):
    line = json.dumps({"file": name, "status": status, "code": code, "request_id": request_id})
    with manifest_lock:
        with open("manifest.jsonl", "a") as manifest:
            manifest.write(line + "\n")

The lock matters because several worker threads write to the same file.

Do the post-processing in the same pass

Bulk jobs rarely stop at a transparent PNG. If every image needs the same treatment, pick the cheapest way to get there:

  • Solid background: call /v2/rembg/color/ with bgcolor set to an RGBA tuple string such as "(255, 255, 255, 255)" and get the final image in one request.
  • Drop shadow: /v2/rembg/shadow/ takes shadow_color, offset, and blur_radius.
  • Several outputs per image: request the transparent PNG once and composite white, brand-color, and social versions locally with Pillow. One request per image, any number of variants.

Every endpoint costs the same per request, so the choice is about how much image work you want to do yourself. The Amazon white background guide shows the local compositing approach in detail.

Scaling beyond one machine

For recurring imports or very large catalogs, move from a script to a job queue such as Celery, RQ, or a cloud task queue. Store each job's status in a database and let workers pick up pending images. The same retry and rate-limit rules still apply. Our production best practices guide covers that architecture in more depth.

Estimate the cost first

At roughly $0.00214 per image on the Pro plan, a 7,000-image batch costs about $15. The same batch on credit-based services can cost hundreds of dollars. See our cheapest background removal API breakdown for a side-by-side comparison, or start with the 100 free requests per month to test the script on a sample folder.

Pick the right plan for the batch

One-off backlogs and recurring imports favor different plans. Using the published prices:

BatchOptionCost
5,000 images, oncePro for one month (7,500 requests)$15
5,000 images, oncePer Usage credits$50
40,000 images, onceUltra for one month (52,000 requests)$79
200,000 images per monthMega (255,000 requests)$199/month
A few hundred images, irregularlyPer Usage credits, which never expire$10 per 1,000

Leave headroom for retries of 4xx failures and for re-processing images you decide to redo with a different model.

Checklist before an overnight run

  • Run the script on 50 to 100 sample images and review the results at full size.
  • If edges are not right, try the falcon, aurora, and ghost models on the same sample and keep the one that looks best.
  • Confirm the plan has enough requests left for the full batch plus headroom.
  • Check free disk or bucket space against the sample's average output size.
  • Run the job in tmux, screen, or a server process so closing a laptop does not kill it.

Frequently asked questions

How long will a bulk job take?

It depends on your plan's per-minute rate limit, image sizes, and your network. Time a sample of 100 images with your real files and worker count, then extrapolate. That number is more reliable than any estimate.

Does re-running the script cost requests for finished images?

No. The script checks for an existing output file before sending a request, so completed images are skipped. Only missing or failed images are sent again.

Can I write the bulk job in Node.js instead of Python?

Yes. The same rules apply: bounded concurrency, honoring Retry-After, and skipping finished files. See the Node.js integration guide for request examples.

What happens if I run out of requests mid-batch?

The API returns 402 insufficient_credits. With the early-stop change above, the job halts cleanly. Upgrade the plan or buy Per Usage credits and re-run to finish the remaining images.