Remove Image Backgrounds in Rust with an API (reqwest multipart, Tokio, Bounded Concurrency)
Rust has excellent image crates and no practical in-process background remover — the segmentation models live in Python and ONNX runtimes that are a heavy dependency for a CLI or a service. An HTTP call is the pragmatic answer, and reqwest's multipart support makes it short. This guide builds a small client for the bgclear API with typed errors, then a Tokio worker that clears a folder of product photos without tripping the rate limit or paying twice for a retried file.
In this guide
Dependencies
[dependencies]
reqwest = { version = "0.12", features = ["multipart", "json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hex = "0.4"Export the key as BGCLEAR_API_KEY; get one on the API dashboard (10 free full-resolution credits; preview-size results are free without limit).
The client: multipart upload with typed errors
use reqwest::{multipart, Client, StatusCode};
use serde::Deserialize;
use std::path::Path;
const API: &str = "https://www.bgclear.ai/api/v1";
#[derive(Debug, Deserialize)]
pub struct ApiError {
pub code: String,
pub message: String,
#[serde(default)]
pub docs: String,
}
#[derive(Debug, Deserialize)]
struct ErrorEnvelope {
error: ApiError,
}
#[derive(Debug)]
pub enum RemoveError {
Api { status: StatusCode, error: ApiError, reset_secs: Option<u64> },
Http(reqwest::Error),
Io(std::io::Error),
}
pub struct BgClear {
http: Client,
key: String,
}
impl BgClear {
pub fn new(key: impl Into<String>) -> Self {
let http = Client::builder()
.timeout(std::time::Duration::from_secs(90))
.build()
.expect("client");
Self { http, key: key.into() }
}
/// size: "preview" (free, ≤800 px) | "full" (1 credit, ≤4 MP sync) | "auto"
pub async fn remove(
&self,
path: &Path,
size: &str,
format: &str,
idempotency_key: Option<&str>,
) -> Result<(Vec<u8>, Option<u64>), RemoveError> {
let form = multipart::Form::new()
.file("image_file", path)
.await
.map_err(RemoveError::Io)?
.text("size", size.to_string())
.text("format", format.to_string());
let mut req = self
.http
.post(format!("{API}/remove"))
.bearer_auth(&self.key)
.multipart(form);
if let Some(k) = idempotency_key {
req = req.header("Idempotency-Key", k);
}
let res = req.send().await.map_err(RemoveError::Http)?;
let status = res.status();
let remaining = res
.headers()
.get("X-Credits-Remaining")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let reset_secs = res
.headers()
.get("X-RateLimit-Reset")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
if !status.is_success() {
let env: ErrorEnvelope = res.json().await.map_err(RemoveError::Http)?;
return Err(RemoveError::Api { status, error: env.error, reset_secs });
}
let bytes = res.bytes().await.map_err(RemoveError::Http)?;
Ok((bytes.to_vec(), remaining))
}
}Every error from the API is JSON with a stable code — insufficient_credits (402), rate_limited (429), image_too_large (400), processing_failed (500/504), gpu_unavailable (503) — so matching on error.code is reliable. Add .text("bg_color", "ffffff") for a white background or .text("crop", "true") to trim to the subject.
JSON mode for hosted images
When the source is already at a URL, skip the multipart and ask for JSON back — the response carries a hosted result URL valid for 24 hours, which suits a pipeline that uploads to S3 next rather than writing a local file.
#[derive(Debug, Deserialize)]
pub struct RemoveResult {
pub id: String,
pub url: String,
pub width: u32,
pub height: u32,
pub credits_charged: u32,
pub credits_remaining: u32,
pub processing_ms: u32,
}
pub async fn remove_url(client: &Client, key: &str, image_url: &str) -> reqwest::Result<RemoveResult> {
client
.post(format!("{API}/remove"))
.bearer_auth(key)
.header("Accept", "application/json")
.json(&serde_json::json!({
"image_url": image_url,
"size": "auto",
"format": "webp",
"bg_color": "ffffff"
}))
.send()
.await?
.error_for_status()?
.json()
.await
}A folder worker under the rate limit
The API allows 60 requests a minute per key. A Tokio interval at one request per second paces submissions; a semaphore bounds in-flight work; the idempotency key is the file's SHA-256, so a retried request after a timeout returns the cached result instead of charging again.
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::sync::{Mutex, Semaphore};
fn file_key(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Arc::new(BgClear::new(std::env::var("BGCLEAR_API_KEY")?));
let limiter = Arc::new(Mutex::new(tokio::time::interval(std::time::Duration::from_secs(1))));
let slots = Arc::new(Semaphore::new(4));
let mut handles = Vec::new();
for entry in std::fs::read_dir("in")? {
let path = entry?.path();
if path.extension().map(|e| e != "jpg" && e != "png").unwrap_or(true) {
continue;
}
let (client, limiter, slots) = (client.clone(), limiter.clone(), slots.clone());
handles.push(tokio::spawn(async move {
let _permit = slots.acquire().await.unwrap();
let key = file_key(&std::fs::read(&path).unwrap());
for attempt in 0..3 {
limiter.lock().await.tick().await; // one submission per second
match client.remove(&path, "full", "png", Some(&key)).await {
Ok((png, remaining)) => {
let out = format!("out/{}.png", path.file_stem().unwrap().to_string_lossy());
std::fs::write(&out, png).unwrap();
println!("{out} (credits left: {remaining:?})");
return;
}
Err(RemoveError::Api { status, error, reset_secs }) => match status.as_u16() {
429 => tokio::time::sleep(std::time::Duration::from_secs(reset_secs.unwrap_or(5))).await,
402 => panic!("out of credits: https://www.bgclear.ai/api-pricing/"),
s if s >= 500 && attempt < 2 => continue,
_ => { eprintln!("{}: {} {}", path.display(), error.code, error.message); return; }
},
Err(e) => { eprintln!("{}: {e:?}", path.display()); return; }
}
}
}));
}
for h in handles { h.await?; }
Ok(())
}Failed requests are never charged, so a crash mid-run costs nothing beyond the rerun. Images above 4 megapixels are rejected by the synchronous endpoint; post them to /api/v1/jobs instead and poll GET /api/v1/jobs/{id} or take a callback — the bulk guide shows both, plus the 50-URL batch endpoint.
Cost and compatibility
Previews are free and unlimited — use size = "preview" in tests. Full-resolution results are one credit each: $9 for 100, $39 for 500, $129 for 2,000; credits never expire (pricing). The field names are remove.bg's, so a Rust service that called api.remove.bg only changes the URL and swaps the X-Api-Key header for bearer_auth before remove.bg's API closes on 1 December 2026 — mapping here.
Frequently asked questions
Is there a crate for the bgclear API?
No; the client above is complete and depends only on reqwest, serde and tokio. A typed client can also be generated from the OpenAPI document at /api/v1/openapi.json with progenitor or openapi-generator.
Can I use the blocking reqwest client?
Yes — reqwest::blocking supports multipart too (Form::file is synchronous there); drop the Tokio worker and pace with std::thread::sleep.
How do I get a transparent PNG versus a white background?
Transparent is the default. Add bg_color=ffffff (any hex) for a solid fill, and format=jpg if you want the smallest marketplace-ready file.
What does a full-resolution image cost?
One credit — $0.065 to $0.09 depending on pack size; previews are free.