API de WhatsApp em Laravel: do config ao canal de Notification

No Laravel, a API de WhatsApp da D-API se encaixa no que o framework já oferece: credenciais em config/services.php, chamadas pela Http facade, envio em Job na fila e um canal de Notification para mandar WhatsApp como se manda e-mail. O webhook entra como uma rota com controller que só enfileira.

A arquitetura em uma frase

Nada de chamar a API no meio do controller. O fluxo que funciona bem em produção tem quatro peças, todas nativas do Laravel: configuração centralizada, um cliente HTTP pré-configurado, Jobs para o envio e para o processamento do que chega, e uma Notification para o código de negócio não precisar saber que existe uma API de WhatsApp por trás. Não há pacote oficial para PHP; tudo abaixo usa o Illuminate\Http que vem com o framework.

Credenciais em config/services.php

Siga o mesmo padrão usado para Mailgun ou Stripe. As chaves ficam no .env e o código lê sempre via config(), o que mantém o php artisan config:cache funcionando.

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

Em seguida, um macro no AppServiceProvider entrega um cliente pronto, com URL base, header de autenticação sem Bearer e timeouts explícitos. Assim nenhum ponto do código esquece o 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));
}

O Job que envia a mensagem

O Job decide o que é falha temporária e o que é erro definitivo. Resposta 4xx significa que o pedido está errado (número inválido, sessão desconectada, campo faltando) e tentar de novo não muda nada. Erro 5xx e timeout podem passar sozinhos, então o Job relança a exceção e o worker agenda outra tentativa usando o $backoff.

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

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

class EnviarWhatsApp 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()) {
            // corpo de erro: { success: false, error, statusCode }
            $this->fail(new RuntimeException($response->json('error', 'Requisição recusada')));
            return;
        }

        $response->throw(); // 5xx vira exceção e o worker tenta de novo
    }
}

Um cuidado honesto: quando o timeout estoura, você não sabe se a mensagem saiu. A nova tentativa pode gerar duplicidade. Para avisos em que isso importa, como cobrança, reduza $tries ou registre o envio antes de repetir. Para quem dispara avisos de pedido, pagamento e entrega, o guia de notificações transacionais aprofunda esse ponto.

Um canal de Notification para WhatsApp

É aqui que a integração fica com cara de Laravel. Com um canal próprio, qualquer Notification pode incluir o WhatsApp no via(), ao lado de mail e database.

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

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

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

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

public function toWhatsApp(object $notifiable): string
{
    return "Olá, {$notifiable->name}! Seu pedido {$this->pedido->codigo} saiu para entrega.";
}

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

O código de negócio continua limpo: $user->notify(new PedidoEnviado($pedido)). Quem decide o canal é a Notification, e quem conhece a API é só o Job.

Esse desenho também facilita regras que costumam aparecer depois: respeitar a preferência de contato do usuário (basta checar um campo dentro do via()), evitar mensagens de madrugada atrasando o dispatch com delay() ou mandar um texto diferente para cada perfil. Nenhuma dessas mudanças toca no Job nem na configuração do cliente HTTP.

Rota e controller do webhook

O webhook entra por routes/api.php, fora do middleware de CSRF. No Laravel 11 em diante esse arquivo é criado com php artisan install:api. O controller valida o token secreto da URL, filtra o evento e despacha um Job. Nada de consulta pesada antes de responder.

// 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')) {
            ProcessarMensagemRecebida::dispatch(
                $request->input('sessionId'),
                $request->input('data'),
            );
        }

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

// dentro de ProcessarMensagemRecebida::handle()
if (! Cache::add('wa:msg:' . $this->data['id'], true, now()->addHour())) {
    return; // evento repetido, já tratado
}

O Cache::add só grava se a chave não existe, o que resolve reenvios do mesmo evento. O webhook de WhatsApp não é assinado, por isso o token na URL e o hash_equals fazem a validação.

Testar sem mandar mensagem de verdade

Como toda chamada passa pela Http facade, o Http::fake intercepta os envios nos testes do PHPUnit ou do Pest. Nenhuma mensagem sai para um número real, e você consegue verificar exatamente o que o Job mandou para a API, inclusive o comportamento diante de uma resposta de erro.

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

(new EnviarWhatsApp('5511999999999', 'Teste'))->withFakeQueueInteractions()->handle();

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

Vale cobrir pelo menos três cenários: envio aceito, resposta 4xx marcando o Job como falho sem nova tentativa, e resposta 5xx relançando a exceção. Para a Notification, o Notification::fake já confirma que o canal certo foi escolhido sem nem chegar ao Job. Com esses testes no CI, uma mudança de payload ou de configuração quebra o build antes de quebrar o atendimento do cliente.

Quando o sistema atende vários clientes

Em um SaaS feito em Laravel, cada cliente conecta o próprio número. Guarde o sessionId no model do tenant e passe para o Job, em vez de usar o valor fixo do config. No webhook, o campo sessionId diz de qual cliente é a mensagem. A organização de muitas conexões está em múltiplos números no WhatsApp, e o modelo comercial para esse perfil em API de WhatsApp para SaaS. Se parte do seu sistema ainda é PHP sem framework, o guia de PHP com cURL cobre esse cenário.

Perguntas frequentes

Existe pacote Laravel da D-API no Packagist?

Não. A integração usa o Http client que já vem no Laravel, porque a API é REST. Isso evita dependência extra e deixa você controlar timeout, tentativas e logs com as ferramentas do próprio framework.

Por que mandar a mensagem por Job e não direto no controller?

Porque o envio é uma chamada de rede. Dentro do request, uma lentidão da rede vira lentidão para o usuário. No Job, a tela responde na hora e o Laravel repete a tentativa com intervalo se algo falhar.

O webhook dá erro 419. O que é?

É a proteção CSRF das rotas web recusando o POST da D-API. Registre a rota do webhook em routes/api.php, que não usa CSRF, ou exclua o caminho da verificação no bootstrap da aplicação.

Consigo usar Laravel Horizon para acompanhar os envios?

Sim. Os Jobs desta página são Jobs comuns em fila Redis, então aparecem no Horizon com tentativas, falhas e tempo de execução, sem nenhuma configuração específica para WhatsApp.

Posso ter um número de WhatsApp por cliente do meu sistema?

Pode. Cada número é uma sessão com seu próprio sessionId. Guarde o sessionId no model do cliente e passe para o Job em vez de ler um valor fixo do config.

Teste a API de WhatsApp da D-API

Trial de 3 dias com acesso completo. Sem cartão, sem fidelidade.