By language

WhatsApp API in Laravel: from config to a Notification channel

In Laravel, the D-API WhatsApp API fits into what the framework already offers: credentials in config/services.php, calls through the Http facade, sending from a queued Job and a Notification channel so you send WhatsApp the way you send email. The webhook comes in as a route with a controller that only enqueues.

By D-API engineering team6 min read

The architecture in one sentence

No calling the API in the middle of a controller. The flow that holds up in production has four pieces, all native to Laravel: centralized configuration, a preconfigured HTTP client, Jobs for sending and for processing what comes in, and a Notification so business code doesn't need to know there is a WhatsApp API behind it. There is no official PHP package; everything below uses the Illuminate\Http that ships with the framework.

Credentials in config/services.php

Follow the same pattern you use for Mailgun or Stripe. Keys live in .env and code always reads them through config(), which keeps php artisan config:cache working.

// config/services.php
'dapi' => [
    'key'           => env('DAPI_API_KEY'),
    'session'       => env('DAPI_SESSION_ID'),
    'webhook_token' => env('DAPI_WEBHOOK_TOKEN'),
],

Next, a macro in AppServiceProvider provides a ready client, with a base URL, the auth header without Bearer and explicit timeouts. That way no part of the code forgets the timeout.

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Http;

public function boot(): void
{
    Http::macro('dapi', fn () => Http::baseUrl('https://api.d-api.cloud')
        ->withHeaders(['Authorization' => config('services.dapi.key')])
        ->acceptJson()
        ->connectTimeout(3)
        ->timeout(10));
}

The Job that sends the message

The Job decides what is a temporary failure and what is a permanent error. A 4xx response means the request is wrong (invalid number, disconnected session, missing field) and retrying changes nothing. 5xx errors and timeouts may clear on their own, so the Job rethrows the exception and the worker schedules another attempt using $backoff.

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

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class SendWhatsApp implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public array $backoff = [10, 30, 60, 120];

    public function __construct(
        public string $to,
        public string $text,
        public ?string $sessionId = null,
    ) {}

    public function handle(): void
    {
        $response = Http::dapi()->post('/api/v1/messages/send/text', [
            'sessionId' => $this->sessionId ?? config('services.dapi.session'),
            'to'        => $this->to,
            'text'      => $this->text,
        ]);

        if ($response->clientError()) {
            // error body: { success: false, error, statusCode }
            $this->fail(new RuntimeException($response->json('error', 'Request rejected')));
            return;
        }

        $response->throw(); // 5xx becomes an exception and the worker retries
    }
}

One honest caveat: when the timeout fires, you don't know whether the message went out. The retry can produce a duplicate. For notices where that matters, such as payment reminders, lower $tries or record the send before retrying. If you send order, payment and delivery updates, the transactional notifications guide goes deeper on this point.

A Notification channel for WhatsApp

This is where the integration starts to feel like Laravel. With a custom channel, any Notification can include WhatsApp in via(), next to mail and database.

// app/Notifications/Channels/WhatsAppChannel.php
namespace App\Notifications\Channels;

use App\Jobs\SendWhatsApp;
use Illuminate\Notifications\Notification;

class WhatsAppChannel
{
    public function send(object $notifiable, Notification $notification): void
    {
        $number = $notifiable->routeNotificationFor('whatsapp', $notification);
        if (! $number) {
            return;
        }
        SendWhatsApp::dispatch($number, $notification->toWhatsApp($notifiable));
    }
}

// app/Notifications/OrderShipped.php
public function via(object $notifiable): array
{
    return ['mail', WhatsAppChannel::class];
}

public function toWhatsApp(object $notifiable): string
{
    return "Hi {$notifiable->name}! Your order {$this->order->code} is out for delivery.";
}

// app/Models/User.php
public function routeNotificationForWhatsApp(): ?string
{
    return $this->whatsapp; // format 14155550123
}

Business code stays clean: $user->notify(new OrderShipped($order)). The Notification picks the channel, and only the Job knows about the API.

This design also makes it easy to add rules that tend to show up later: respecting the user's contact preference (just check a field inside via()), avoiding messages in the middle of the night by delaying the dispatch with delay(), or sending different text to each profile. None of these changes touch the Job or the HTTP client configuration.

Webhook route and controller

The webhook comes in through routes/api.php, outside the CSRF middleware. From Laravel 11 on, that file is created with php artisan install:api. The controller validates the secret token in the URL, filters the event and dispatches a Job. No heavy queries before responding.

// routes/api.php
Route::post('/webhooks/whatsapp/{token}', WhatsAppWebhookController::class);

// app/Http/Controllers/WhatsAppWebhookController.php
class WhatsAppWebhookController
{
    public function __invoke(Request $request, string $token): JsonResponse
    {
        abort_unless(hash_equals(config('services.dapi.webhook_token'), $token), 401);

        if ($request->input('event') === 'messages.received' && ! $request->boolean('data.fromMe')) {
            ProcessIncomingMessage::dispatch(
                $request->input('sessionId'),
                $request->input('data'),
            );
        }

        return response()->json(['ok' => true]);
    }
}

// inside ProcessIncomingMessage::handle()
if (! Cache::add('wa:msg:' . $this->data['id'], true, now()->addHour())) {
    return; // repeated event, already handled
}

Cache::add only writes if the key does not exist, which takes care of redeliveries of the same event. The WhatsApp webhook is not signed, so the token in the URL and hash_equals do the validation.

Test without sending real messages

Since every call goes through the Http facade, Http::fake intercepts sends in PHPUnit or Pest tests. No message goes out to a real number, and you can check exactly what the Job sent to the API, including how it behaves on an error response.

Http::fake([
    'api.d-api.cloud/api/v1/messages/send/text' => Http::response(
        ['success' => false, 'error' => 'Session not connected', 'statusCode' => 400], 400
    ),
]);

(new SendWhatsApp('14155550123', 'Test'))->withFakeQueueInteractions()->handle();

Http::assertSent(fn ($request) =>
    $request->hasHeader('Authorization', config('services.dapi.key'))
    && $request['to'] === '14155550123'
);

Cover at least three scenarios: an accepted send, a 4xx response marking the Job as failed with no retry, and a 5xx response rethrowing the exception. For the Notification, Notification::fake already confirms the right channel was chosen without even reaching the Job. With these tests in CI, a payload or configuration change breaks the build before it breaks customer support.

When the system serves many customers

In a SaaS built on Laravel, each customer connects their own number. Store the sessionId on the tenant model and pass it to the Job instead of using the fixed config value. In the webhook, the sessionId field tells you which customer the message belongs to. Organizing many connections is covered in multiple WhatsApp numbers, and the commercial model for this profile in WhatsApp API for SaaS. If part of your system is still PHP without a framework, the PHP guide with cURL covers that scenario.

Frequently asked questions

Is there a D-API Laravel package on Packagist?
No. The integration uses the Http client that ships with Laravel, because the API is REST. That avoids an extra dependency and lets you control timeouts, retries and logs with the framework’s own tools.
Why send the message from a Job instead of straight from the controller?
Because sending is a network call. Inside the request, network slowness becomes slowness for the user. In a Job, the screen responds right away and Laravel retries with a delay if something fails.
The webhook returns a 419 error. What is that?
It is CSRF protection on web routes rejecting the POST from D-API. Register the webhook route in routes/api.php, which does not use CSRF, or exclude the path from verification in the application bootstrap.
Can I use Laravel Horizon to monitor sends?
Yes. The Jobs on this page are ordinary Jobs on a Redis queue, so they show up in Horizon with attempts, failures and run time, without any WhatsApp-specific configuration.
Can each customer of my system have their own WhatsApp number?
Yes. Each number is a session with its own sessionId. Store the sessionId on the customer model and pass it to the Job instead of reading a fixed value from config.

Try D-API's WhatsApp API

3-day trial with full access. No credit card, no lock-in.