Handling Delayed API Responses with Laravel Jobs and Callbacks

πŸ“– 2 minutes read

Some third-party APIs don’t give you an instant answer. You send a request, they return "status": "processing", and you’re expected to poll until the result is ready. Payment gateways do this a lot β€” especially for bank transfers and manual review flows.

Here’s the pattern that’s worked well for handling this in Laravel.

The Problem

Your controller sends a request to an external API. Instead of a final result, you get:

{
    "transaction_id": "txn_abc123",
    "status": "processing",
    "estimated_completion": "30s"
}

You can’t block the HTTP request for 30 seconds. But you also can’t just ignore it β€” your workflow depends on the result.

The Solution: Dispatch a Polling Job

// In your service
public function initiatePayment(Order $order): void
{
    $response = Http::post('https://api.provider.com/charge', [
        'amount' => $order->total,
        'reference' => $order->reference,
    ]);

    if ($response->json('status') === 'processing') {
        PollPaymentStatus::dispatch(
            transactionId: $response->json('transaction_id'),
            orderId: $order->id,
            attempts: 0,
        )->delay(now()->addSeconds(10));
    }
}

The Polling Job

class PollPaymentStatus implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    private const MAX_ATTEMPTS = 10;
    private const POLL_INTERVAL = 15; // seconds

    public function __construct(
        private readonly string $transactionId,
        private readonly int $orderId,
        private readonly int $attempts,
    ) {}

    public function handle(): void
    {
        $response = Http::get(
            "https://api.provider.com/status/{$this->transactionId}"
        );

        $status = $response->json('status');

        if ($status === 'completed') {
            $this->onSuccess($response->json());
            return;
        }

        if ($status === 'failed') {
            $this->onFailure($response->json('error'));
            return;
        }

        // Still processing β€” re-dispatch with backoff
        if ($this->attempts >= self::MAX_ATTEMPTS) {
            $this->onTimeout();
            return;
        }

        self::dispatch(
            $this->transactionId,
            $this->orderId,
            $this->attempts + 1,
        )->delay(now()->addSeconds(self::POLL_INTERVAL));
    }

    private function onSuccess(array $data): void
    {
        $order = Order::find($this->orderId);
        $order->markAsPaid($data['reference']);
        // Continue your workflow...
    }

    private function onFailure(string $error): void
    {
        Log::error("Payment failed: {$error}", [
            'transaction_id' => $this->transactionId,
        ]);
    }

    private function onTimeout(): void
    {
        Log::warning("Payment polling timed out after " . self::MAX_ATTEMPTS . " attempts");
    }
}

Why This Works

The job re-dispatches itself with a delay, creating a non-blocking polling loop. Your queue worker handles the timing. Your controller returns immediately. And you get clean callback methods (onSuccess, onFailure, onTimeout) for each outcome.

The key insight: the job IS the polling loop. Each dispatch is one iteration. The delay between dispatches is your poll interval. And the max attempts give you a clean exit.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *