Laravel: Bulk Background Removal with Async Jobs, Signed Callback URLs and a Reconciliation Command
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_id — ProductImage::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.