Django and FastAPI: a Background Removal Proxy Endpoint That Keeps Your API Key Safe
Whatever the front end — React, Flutter, a WordPress site — the background-removal call belongs on your server, where the API key lives and where you can meter usage per user. In Python that server is usually FastAPI or Django. This guide builds the proxy endpoint in both, with httpx for the upstream call, streaming so a 10 MB PNG is not buffered twice, and a Celery task for catalogue runs with retries that cannot double-charge.
In this guide
FastAPI + httpx
import os, hashlib
import httpx
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends
from fastapi.responses import StreamingResponse
app = FastAPI()
BGCLEAR = "https://www.bgclear.ai/api/v1/remove"
client = httpx.AsyncClient(timeout=httpx.Timeout(90.0))
@app.post("/cutout")
async def cutout(image: UploadFile = File(...), size: str = "preview", user=Depends(current_user)):
if size not in ("preview", "full"):
raise HTTPException(400, "size must be preview or full")
data = await image.read()
if len(data) > 25 * 1024 * 1024:
raise HTTPException(413, "max 25 MB")
if size == "full" and not user.can_spend_credit():
raise HTTPException(402, "quota exceeded")
r = await client.post(
BGCLEAR,
headers={
"Authorization": f"Bearer {os.environ['BGCLEAR_API_KEY']}",
"Idempotency-Key": f"{user.id}-{size}-{hashlib.sha256(data).hexdigest()}",
},
files={"image_file": (image.filename, data, image.content_type or "image/jpeg")},
data={"size": size, "format": "png"},
)
if r.status_code != 200:
# pass the API's JSON error through: {"error": {"code", "message", "docs"}}
raise HTTPException(r.status_code, r.json().get("error", {}).get("message", "upstream error"))
if size == "full":
user.record_credit_spent()
return StreamingResponse(iter([r.content]), media_type=r.headers.get("content-type", "image/png"),
headers={"X-Credits-Remaining": r.headers.get("X-Credits-Remaining", "")})size=preview is free at the API (≤800 px), so let anonymous users preview and gate full behind login and a quota. The SHA-256 idempotency key means a browser retry of the same file within 24 hours returns the cached result without a second charge.
Django view
import hashlib, os, requests
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, JsonResponse
from django.views.decorators.http import require_POST
@login_required
@require_POST
def cutout(request):
image = request.FILES.get("image")
size = "full" if request.GET.get("size") == "full" else "preview"
if not image:
return JsonResponse({"error": "image missing"}, status=400)
if image.size > 25 * 1024 * 1024:
return JsonResponse({"error": "max 25 MB"}, status=413)
data = image.read()
r = requests.post(
"https://www.bgclear.ai/api/v1/remove",
headers={"Authorization": f"Bearer {settings.BGCLEAR_API_KEY}",
"Idempotency-Key": f"{request.user.pk}-{size}-{hashlib.sha256(data).hexdigest()}"},
files={"image_file": (image.name, data, image.content_type)},
data={"size": size, "format": "png"},
timeout=90,
)
if r.status_code != 200:
return JsonResponse(r.json(), status=r.status_code)
resp = HttpResponse(r.content, content_type=r.headers.get("Content-Type", "image/png"))
resp["X-Credits-Remaining"] = r.headers.get("X-Credits-Remaining", "")
return respBGCLEAR_API_KEY = os.environ["BGCLEAR_API_KEY"] in settings; add a per-user daily counter (a Redis INCR with expiry is enough) before allowing size=full.
Bulk: a Celery task with safe retries
import hashlib, os, requests
from celery import shared_task
@shared_task(bind=True, max_retries=5)
def remove_background(self, path: str, out: str):
with open(path, "rb") as f:
data = f.read()
r = requests.post(
"https://www.bgclear.ai/api/v1/remove",
headers={"Authorization": f"Bearer {os.environ['BGCLEAR_API_KEY']}",
"Idempotency-Key": hashlib.sha256(data).hexdigest()},
files={"image_file": (os.path.basename(path), data)},
data={"size": "full", "format": "webp", "bg_color": "ffffff"},
timeout=120,
)
if r.status_code == 429:
raise self.retry(countdown=int(r.headers.get("X-RateLimit-Reset", 10)))
if r.status_code == 402:
raise RuntimeError("out of credits — https://www.bgclear.ai/api-pricing/")
if r.status_code >= 500:
raise self.retry(countdown=30)
r.raise_for_status()
with open(out, "wb") as f:
f.write(r.content)
return r.headers.get("X-Credits-Remaining")Rate limit is 60 requests a minute per key, so set the worker's rate_limit="50/m" on the task and let Celery pace it. Images above 4 megapixels go to /api/v1/jobs with an X-Callback-Url pointing at a small FastAPI/Django receiver — the bulk guide shows the receiver and the batch endpoint for 50 URLs per call.
Cost and compatibility
Previews are free and unlimited; full-resolution images are one credit — $9 for 100, $39 for 500, $129 for 2,000, no subscription, credits never expire, failures never charged (pricing). The request fields (image_file, image_url, size, format, bg_color, crop) are remove.bg's, so a Django app that used remove.bg only changes the URL and header before its API closes on 1 December 2026 — mapping here. The full Python client walkthrough, including folder scripts and async jobs, is in the Python tutorial.
Frequently asked questions
Why not call the API from the browser with CORS?
Because the key would be visible in the network tab. The proxy costs 30 lines and gives you auth, quotas and logging.
Sync or async httpx?
Async (AsyncClient) in FastAPI so a 5-second removal does not block the event loop; in Django's sync views, requests is fine, or use the async view with httpx.AsyncClient.
Should I store the result or the API's result URL?
Store the bytes (or upload them to your object storage). The API's hosted result URL is convenient but expires after 24 hours.
How much does it cost?
One credit per full-resolution image ($0.065–0.09 depending on pack); preview-size results are free.