WhatsApp API in Laravel: Jobs, Queues & Webhooks

Nov 5, 2026  •  5 min read
WhatsApp API in Laravel: Jobs, Queues & Webhooks

Laravel's HTTP client makes the simplest version of this almost too easy:

php

use Illuminate\Support\Facades\Http;

Http::withToken(config('services.wahttp.api_key'))
->post(config('services.wahttp.base_url') . '/sessions/' . config('services.wahttp.session_id') . '/messages', [
'to' => '+14155551234',
'type' => 'text',
'body' => 'Hello from Laravel',
]);

That works, but it's the wrong shape for anything a real user is waiting on. It runs synchronously inside the request, so a slow network call to WhatsApp's API blocks a web worker until it returns, and if it fails, nothing retries. Everything below turns that one call into something you'd actually deploy: a queued job with real retry behavior, built-in rate limiting using middleware Laravel already ships, and a webhook controller that acknowledges fast instead of making WhatsApp wait on your business logic.

Configuration first

Add the credentials to config/services.php rather than scattering env() calls through your codebase:

php

// config/services.php
'wahttp' => [
'api_key' => env('WAHTTP_API_KEY'),
'session_id' => env('WAHTTP_SESSION_ID'),
'base_url' => env('WAHTTP_BASE_URL', 'https://app.wahttp.com/api'),
],

bash

# .env
WAHTTP_API_KEY=wahttp_live_xxxxxxxxxxxx
WAHTTP_SESSION_ID=your_session_id

Use a wahttp_test_ key in .env.testing and local development, and only point .env at a wahttp_live_ key in production. Same rule as every other language in this series: the session behind that ID has to show as connected in your WaHttp dashboard, QR-scanned from the phone you're sending from, before any of the code below will actually deliver anything.

The job that should be doing the sending

This is the part that's specific to Laravel and worth the title of this post. Wrap the send in a job rather than calling the HTTP client directly from a controller or command:

php

// app/Jobs/SendWhatsAppMessage.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\Http;

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

public int $tries = 4;
public array $backoff = [5, 15, 30, 60];

public function __construct(
public string $to,
public string $body,
) {}

public function middleware(): array
{
return [new RateLimited('whatsapp-sends')];
}

public function handle(): void
{
$response = Http::withToken(config('services.wahttp.api_key'))
->timeout(15)
->post(config('services.wahttp.base_url') . '/sessions/' . config('services.wahttp.session_id') . '/messages', [
'to' => $this->to,
'type' => 'text',
'body' => $this->body,
]);

if ($response->status() === 429 || $response->serverError()) {
$this->release(60); // let the backoff array above handle the delay on retry
return;
}

$response->throw(); // throws on remaining 4xx, which should not be retried
}
}

Dispatch it from anywhere without touching HTTP directly:

php

SendWhatsAppMessage::dispatch('+14155551234', 'Your order has shipped.');

Two things are doing real work in that class. $tries and $backoff give you retry behavior for free, retrying transient failures on an increasing delay rather than a human writing a retry loop by hand. And the 429/server-error check calls $this->release() rather than letting the job fail outright, which hands control back to the queue rather than burning through your limited $tries on errors that are specifically meant to be retried.

Rate limiting without writing a rate limiter

This is the piece that's genuinely easier in Laravel than in the plain-PHP version of this tutorial. Define the limiter once in a service provider:

php

// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
RateLimiter::for('whatsapp-sends', function () {
return Limit::perMinute(10); // tune to your own sending pattern
});
}

Every job carrying the RateLimited('whatsapp-sends') middleware now respects that limit automatically, queue-wide, across every worker processing the queue. That's the built-in answer to the rate-limiting problem every language in this series has to solve by hand with sleep() and randomized delays: Laravel's queue system already has a primitive for exactly this, and it coordinates correctly across multiple workers in a way a per-process sleep never could. It's still worth randomizing within that limit for a bulk broadcast rather than firing at a perfectly even cadence, since rhythm matters as much as raw throughput to WhatsApp's own detection.

Receiving messages: a webhook controller that doesn't block

Route the webhook to a controller, and keep the controller itself doing almost nothing:

php

// routes/api.php
Route::post('/webhooks/whatsapp', [WhatsAppWebhookController::class, 'handle']);

php

// app/Http/Controllers/WhatsAppWebhookController::class
namespace App\Http\Controllers;

use App\Jobs\ProcessWhatsAppWebhook;
use Illuminate\Http\Request;

class WhatsAppWebhookController extends Controller
{
public function handle(Request $request)
{
ProcessWhatsAppWebhook::dispatch($request->all());
return response()->json(['ok' => true]);
}
}

php

// app/Jobs/ProcessWhatsAppWebhook.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;

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

public function __construct(public array $payload) {}

public function handle(): void
{
if (($this->payload['event'] ?? null) !== 'message.received') {
return;
}

$data = $this->payload['data'];
if (!empty($data['isGroup'])) {
return; // don't auto-reply inside groups
}

SendWhatsAppMessage::dispatch($data['from'], "You said: {$data['body']}");
}
}

The controller's entire job is to acknowledge WhatsApp immediately and hand the payload to a queued job, which is the Laravel-native version of the "return 200 before doing slow work" rule every language in this series follows. Doing it this way also means a webhook that arrives while your queue worker is briefly down doesn't get lost, since the job is in the queue, not dependent on the controller finishing synchronously. If you're verifying a webhook signature, that check belongs in the controller before dispatching, not inside the job, since there's no reason to queue work for a request you're about to reject anyway.

Running the worker

None of the above does anything until a worker is actually processing the queue:

bash

php artisan queue:work --tries=4

In production, that process needs a supervisor, Supervisor itself, or Laravel Horizon if you're on a Redis queue connection and want a dashboard over what's running, retrying and failing. Horizon specifically is worth adopting here beyond the dashboard: its metrics make a silently stuck webhook-processing queue visible in a way a bare queue:work process running under Supervisor doesn't surface on its own.

What Laravel actually buys you over plain PHP

Our plain-PHP WhatsApp API tutorial covers the same send-and-receive loop with nothing but cURL, and flags rate limiting, retries and backoff as problems it deliberately leaves for the reader to solve. Laravel's queue system is the actual answer to all three: retries and backoff are two public properties on a job class, rate limiting is a named limiter shared correctly across every worker, and webhook processing moves off the request thread without hand-rolling a shutdown function or a background-task API. None of that is unique to WhatsApp, it's just Laravel's queue system doing what it's built for, applied to this specific problem.

What this costs once you're past the trial

Everything above runs against a free trial, capped at 100 messages a day for seven days, no card required. Past that, WaHttp's pricing runs $5 to $35 a month for one to ten sessions with unlimited messages on every tier, no per-message fee stacked on top the way Meta's official Cloud API charges. Our 2026 pricing breakdown has the full comparison against the official platform's per-message rates.

Which endpoint shape you're actually coding against

Everything here assumes a session-based REST API, where a plain-text body can go to anyone at any time, which is why none of this deals with template names or approval states. On Meta's official Cloud API, the same job becomes a template send outside the 24-hour customer service window, with variable arrays instead of a free-text body. The job-and-queue structure above still applies either way, but the payload inside handle() changes shape, so our webhooks deep dive is worth reading before you build the signature-verification piece the controller above left as a one-line reminder.

WhatsApp