API

Laravel: Bulk Background Removal with Async Jobs, Signed Callback URLs and a Reconciliation Command

September 6, 20267 min readBy BG Clear Editorial

The earlier PHP guide used the synchronous endpoint from a queued job, which is fine up to a few hundred images at 4 megapixels or less. Catalogues are bigger and images are larger, and that is what the async jobs endpoint is for: submit, get a job id, receive a callback. This post is the Laravel shape of that — a rate-limited submit job, a signed callback route, an authoritative status check before anything is trusted, and an Artisan command that reconciles jobs that never called back. Failed jobs are never charged, so the design is about completeness, not cost.

In this guide

How the async endpoint behaves

POST https://www.bgclear.ai/api/v1/jobs takes the same inputs and parameters as the synchronous endpoint — image_file or image_url, size, format, bg_color, crop — accepts images up to 50 MP, and answers 202 {"job_id": "…", "status": "queued"}. POST /api/v1/jobs/batch takes up to 50 image_urls in one JSON body and returns job_ids in the same order. If you send an X-Callback-Url header, the API POSTs to it when the job completes with {"job_id", "status": "done", "url", "credits_charged", "credits_remaining"}, retrying three times on network errors or 5xx.

Two facts shape the design. Callbacks are sent on success; a job that fails is visible only through GET /api/v1/jobs/{id} (status: "failed" with an error), so you need a reconciliation pass. And the X-Bgclear-Signature header is an HMAC over the body with a shared secret — per-key secrets are on the roadmap — so the docs tell you to treat a callback as a hint and confirm the job with your own authenticated GET before acting. Laravel's signed routes give you the missing authenticity on your side: the callback URL itself carries a signature only your app could have generated.

Schema and the submit job

Track every submission; the job id is what lets you resume and reconcile.

// migration
Schema::table('product_images', function (Blueprint $table) {
    $table->string('bgclear_job_id')->nullable()->index();
    $table->string('bgclear_status')->nullable();   // queued | done | failed
    $table->string('cutout_path')->nullable();
    $table->timestamp('bgclear_submitted_at')->nullable();
});
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\{Http, Storage, URL};

class SubmitCutoutJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public function backoff(): array { return [15, 30, 60, 120, 300]; }
    public function middleware(): array { return [new RateLimited('bgclear')]; }   // 50/min, see below

    public function __construct(public int $imageId) {}

    public function handle(): void
    {
        $image = ProductImage::findOrFail($this->imageId);
        if ($image->bgclear_job_id) { return; }                       // already submitted

        // Signed URL: only our app can mint it, and it names the image it belongs to.
        $callback = URL::signedRoute('bgclear.callback', ['image' => $image->id]);

        $response = Http::withToken(config('services.bgclear.key'))
            ->withHeaders(['X-Callback-Url' => $callback])
            ->timeout(60)
            ->attach('image_file', Storage::get($image->path), basename($image->path))
            ->post('https://www.bgclear.ai/api/v1/jobs', [
                'size' => 'full', 'format' => 'webp', 'bg_color' => 'ffffff',
            ]);

        if ($response->status() === 503) {                              // queue full: Retry-After
            $this->release((int) $response->header('Retry-After', 30));
            return;
        }
        if ($response->status() === 402) {
            $this->fail(new \RuntimeException('Out of credits — https://www.bgclear.ai/api-pricing/'));
            return;
        }
        $response->throw();

        $image->forceFill([
            'bgclear_job_id' => $response->json('job_id'),
            'bgclear_status' => 'queued',
            'bgclear_submitted_at' => now(),
        ])->save();
    }
}

Register the limiter in a service provider so submissions stay under the API's 60 requests a minute per key: RateLimiter::for('bgclear', fn () => Limit::perMinute(50));. Dispatch from an Artisan command that walks the catalogue; images already holding a bgclear_job_id are skipped, which makes the command safe to re-run.

The callback route: verify, confirm, then download

// routes/api.php
Route::post('/hooks/bgclear/{image}', BgclearCallbackController::class)
    ->name('bgclear.callback')
    ->middleware('signed')                 // rejects URLs we did not mint or that were tampered with
    ->withoutMiddleware(VerifyCsrfToken::class);
class BgclearCallbackController
{
    public function __invoke(Request $request, ProductImage $image)
    {
        $jobId = (string) $request->input('job_id');
        if ($jobId !== $image->bgclear_job_id) {
            return response()->json(['ignored' => 'job id mismatch'], 200);   // 2xx: do not trigger retries
        }
        FinalizeCutoutJob::dispatch($image->id);   // ack fast; do the work off the request
        return response()->noContent();
    }
}

class FinalizeCutoutJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    public int $tries = 3;

    public function __construct(public int $imageId) {}

    public function handle(): void
    {
        $image = ProductImage::findOrFail($this->imageId);

        // Authoritative: the callback is a hint, this is the truth (and it is authenticated with our key).
        $job = Http::withToken(config('services.bgclear.key'))
            ->get("https://www.bgclear.ai/api/v1/jobs/{$image->bgclear_job_id}")
            ->throw()->json();

        if ($job['status'] === 'done') {
            $bytes = Http::timeout(60)->get($job['url'])->throw()->body();   // result URLs need no auth, 24 h
            $path = "catalogue/{$image->id}.webp";
            Storage::put($path, $bytes);
            $image->forceFill(['bgclear_status' => 'done', 'cutout_path' => $path])->save();
        } elseif ($job['status'] === 'failed') {
            $image->forceFill(['bgclear_status' => 'failed'])->save();      // not charged
            report(new \RuntimeException("bgclear job failed: {$job['error']}"));
        } else {
            $this->release(30);                                             // still processing
        }
    }
}

Two details do most of the work here. The signed middleware means a stranger cannot make your app download an arbitrary URL by POSTing to the hook: they cannot produce a valid signature for /hooks/bgclear/123. And because the finalize job re-reads the job from the API with your key, a forged body cannot inject a bogus url either — you only ever download what the API says belongs to that job id.

Reconcile: the jobs that never called back

Callbacks arrive on success; failures do not call back, and a callback can be lost if your app was down. A scheduled command closes the gap by polling anything still queued after a grace period.

class ReconcileCutouts extends Command
{
    protected $signature = 'bgclear:reconcile {--minutes=10}';

    public function handle(): int
    {
        ProductImage::where('bgclear_status', 'queued')
            ->where('bgclear_submitted_at', '<', now()->subMinutes((int) $this->option('minutes')))
            ->chunkById(100, function ($images) {
                foreach ($images as $image) {
                    FinalizeCutoutJob::dispatch($image->id);   // reads the true status, downloads if done
                    usleep(200_000);                            // gentle: 5/s well under the rate limit
                }
            });
        return self::SUCCESS;
    }
}

Schedule it every ten minutes ($schedule->command('bgclear:reconcile')->everyTenMinutes();). Result files stay downloadable for 24 hours, so a reconciliation pass a few minutes late loses nothing. A job that reports failed was never charged; requeue the image with a fresh SubmitCutoutJob if you believe it was transient.

Batch submission for URL-based sources

When the source images are already hosted (a PIM, a supplier CDN, S3 with signed URLs), skip the upload and submit 50 at a time. One request per batch keeps you far inside the rate limit; job_ids[i] corresponds to images[i].

$chunk = $images->take(50);                                        // Collection of ProductImage with ->source_url
$response = Http::withToken(config('services.bgclear.key'))
    ->withHeaders(['X-Callback-Url' => URL::signedRoute('bgclear.batch-callback')])
    ->post('https://www.bgclear.ai/api/v1/jobs/batch', [
        'size' => 'full', 'format' => 'webp', 'bg_color' => 'ffffff',
        'images' => $chunk->map(fn ($i) => ['image_url' => $i->source_url])->values()->all(),
    ])->throw();

foreach ($chunk->values() as $idx => $image) {
    $image->forceFill(['bgclear_job_id' => $response->json("job_ids.$idx"), 'bgclear_status' => 'queued',
                       'bgclear_submitted_at' => now()])->save();
}

For batches, the callback route cannot carry an image id in the URL (one URL serves 50 jobs), so the controller looks the image up by job_idProductImage::where('bgclear_job_id', $request->input('job_id'))->first() — and otherwise behaves exactly like the single-image one. Batch is available with direct bgclear keys, not via RapidAPI. Pricing is one credit per full-resolution image with no subscription (details); the broader pattern, including a Node receiver, is in the bulk guide, and the synchronous Laravel version is in the PHP/Laravel tutorial.

Frequently asked questions

Why not verify the X-Bgclear-Signature header?

Today it is an HMAC with a shared server secret rather than a per-key secret, so it cannot prove the sender to you. Laravel's signed route proves the URL is yours, and re-reading the job with your API key proves the result is real. Per-key signing is on the roadmap.

How fast do callbacks arrive?

As soon as the job finishes — typically seconds for a single image, longer when the shared GPU queue is deep. The reconcile command covers anything that slips.

What happens if my app is down when the callback fires?

The API retries three times with backoff on network errors or 5xx. If all fail, the reconcile command finds the job on its next run and downloads the result, which stays available for 24 hours.

What does a catalogue run cost?

One credit per successfully processed full-resolution image: $9 for 100, $39 for 500, $129 for 2,000. Failed jobs and preview-size jobs cost nothing, and credits never expire.

Ship it with the bgclear API

remove.bg-compatible endpoints, credits from $9 for 100 images, no subscription. Pay, get a key, make your first call in under two minutes.

Get API credits →

Tools for this guide

Background Removal API Free

background removal api free

Cheapest Background Remover API

cheapest background remover api

Image Segmentation API

image segmentation api

Keep reading

API

Remove Image Backgrounds in PHP and Laravel with an API (cURL, Guzzle, Http Client)

Plain PHP with cURL, Guzzle multipart, Laravel's Http client with attach(), a queued job for bulk, and the two-line change if you used the remove.bg PHP package.

API

Bulk Background Removal via API: Async Jobs, Batches of 50 and Webhooks

How to process a catalogue of thousands of images without babysitting a script: batch submission, callbacks instead of polling, credit accounting, and what to do when the queue is full.

API

WooCommerce: Remove Product Image Backgrounds Automatically (WP-CLI Command + API)

A small mu-plugin that adds a WP-CLI command to run every WooCommerce product image through a background removal API with a white fill, save the result to the media library and set it as the product image.

API

Shopify: Give Every Product Photo a White Background Automatically (Admin API + Background Removal API)

A script that walks your Shopify catalogue, sends each image through a background removal API with a white fill, and re-attaches the result with productCreateMedia — no app, no manual editing.