Notificações de pedido pelo WhatsApp com Laravel: filas e status

Capa do artigo Notificações de pedido pelo WhatsApp com Laravel: filas e status

Notificações de pedido pelo WhatsApp são a parte do e-commerce que o cliente mais percebe depois do pagamento: “pedido confirmado”, “pagamento aprovado”, “saiu para entrega com rastreio”. Quando chegam na hora, cortam a maior parte dos “cadê meu pedido” no suporte; quando chegam duplicadas, atrasadas ou com o nome errado, viram motivo de reclamação.

Em Laravel, a estrutura certa para isso já vem no framework: o cliente Http para chamar a API, a Queue para tirar o envio da requisição do usuário, retentativas com backoff e um scheduler para o que precisa de acompanhamento. A API de WhatsApp da APIBrasil entra como o canal de envio, com um endpoint de texto simples e um webhook que devolve o status de cada mensagem.

O que segue é um desenho completo: quais eventos avisar, o job que envia com retry, os templates por status, o webhook que confirma a entrega e o fallback por SMS quando o WhatsApp não chega, tudo com opt-in registrado.

Quais notificações de pedido pelo WhatsApp valem a pena

A tentação é avisar tudo. Na prática, cinco eventos cobrem a jornada sem cansar o cliente, e cada um tem uma informação que justifica a mensagem:

EventoO que a mensagem entregaQuando disparar
Pedido confirmadoNúmero do pedido e itens resumidosLogo após o checkout
Pagamento aprovadoConfirmação e prazo estimadoAo receber o retorno do gateway
EnviadoCódigo de rastreio e linkAo registrar a postagem
EntregueConfirmação e canal para problemasAo receber o evento da transportadora
Pesquisa de satisfaçãoUma pergunta, resposta pelo próprio chatUm ou dois dias após a entrega

Pedidos que nascem fora do checkout tradicional seguem a mesma jornada. Quem vende em transmissão ao vivo, por exemplo, fecha dezenas de pedidos em minutos e precisa confirmar cada um no WhatsApp antes que o cliente esfrie; o guia sobre como vender na Shopee Live mostra esse fluxo do lado de quem transmite. Para o seu sistema, é só mais uma origem de pedido entrando na mesma fila.

Setup: Laravel, cliente Http e Queue

O projeto precisa de três coisas: as credenciais da APIBrasil em config, uma fila funcionando e uma tabela para registrar cada notificação. Comece pelo .env e pelo config/services.php, que é o lugar certo para credenciais de serviços externos no Laravel:

// .env
APIBRASIL_TOKEN=seu_bearer_token
APIBRASIL_DEVICE_TOKEN=device_token_do_dispositivo_whatsapp
APIBRASIL_WEBHOOK_SECRET=um-uuid-dificil-de-adivinhar
QUEUE_CONNECTION=database

// config/services.php
'apibrasil' => [
    'base_url'       => env('APIBRASIL_BASE_URL', 'https://gateway.apibrasil.io/api/v2'),
    'token'          => env('APIBRASIL_TOKEN'),
    'device_token'   => env('APIBRASIL_DEVICE_TOKEN'),
    'webhook_secret' => env('APIBRASIL_WEBHOOK_SECRET'),
    'session_key'    => env('APIBRASIL_SESSION_KEY'), // opcional: assinatura do webhook
],

O DeviceToken é específico das APIs de WhatsApp da APIBrasil, que são cobradas por plano e exigem um dispositivo conectado no painel; as demais APIs usam só o Bearer Token. A fila em banco é suficiente para começar (php artisan make:queue-table e migrate); em produção com volume, troque por Redis sem mudar o código do job.

A tabela de notificações é o que torna o sistema auditável e idempotente. Cada linha representa um aviso de um evento para um pedido, e a chave única impede que o mesmo evento gere duas mensagens:

Schema::create('notificacoes_pedido', function (Blueprint $table) {
    $table->id();
    $table->foreignId('pedido_id')->constrained('pedidos');
    $table->string('evento');                       // confirmado, pago, enviado, entregue, pesquisa
    $table->string('telefone', 20);                 // 5511999999999 (só dígitos)
    $table->text('texto');
    $table->string('status')->default('pendente');  // pendente, enviada, falhou
    $table->string('mensagem_id')->nullable();      // id devolvido pela API
    $table->string('status_entrega')->nullable();   // SENT, RECEIVED, READ, FAILED...
    $table->text('erro')->nullable();
    $table->timestamp('enviada_em')->nullable();
    $table->timestamp('fallback_em')->nullable();   // quando o SMS de reserva saiu
    $table->timestamps();
    $table->unique(['pedido_id', 'evento']);        // um aviso por evento, nunca dois
});
Infográfico da jornada de notificações de pedido pelo WhatsApp: pedido confirmado, pagamento aprovado, enviado com rastreio, entregue e pesquisa de satisfação

Job de envio com retry

O envio nunca acontece dentro do controller ou do webhook do gateway de pagamento. Ele vira um job na fila, que tenta, espera e tenta de novo sem segurar a resposta para o usuário. Três peças: um serviço que fala com a API, o job que usa o serviço e o ponto em que o job é despachado.

O serviço que chama a APIBrasil

O endpoint de texto é POST /whatsapp/sendText, com number (formato internacional, só dígitos) e text. Como em toda API do gateway, o erro pode vir com HTTP 200 e error igual a true no corpo, então o serviço confere os dois e carrega o status HTTP na exceção para o job decidir se repete:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class ApiBrasilWhatsApp
{
    public function enviarTexto(string $numero, string $texto): array
    {
        $resposta = Http::baseUrl(config('services.apibrasil.base_url'))
            ->withToken(config('services.apibrasil.token'))
            ->withHeaders(['DeviceToken' => config('services.apibrasil.device_token')])
            ->timeout(15)
            ->post('/whatsapp/sendText', [
                'number' => $numero,
                'text'   => $texto,
            ]);

        $corpo = $resposta->json() ?? [];

        // Erro pode chegar com HTTP 200: sempre confira "error"
        if ($resposta->failed() || ($corpo['error'] ?? true) === true) {
            throw new RuntimeException(
                $corpo['message'] ?? 'Falha ao enviar WhatsApp',
                $resposta->status() // o código HTTP viaja na exceção
            );
        }

        return $corpo; // o id da mensagem vem na resposta; confira o caminho exato na documentação
    }
}

O job

O job carrega a notificação, não o texto solto, para que uma mudança de status no banco (cancelada, já enviada) seja respeitada mesmo que o job tenha sido enfileirado antes. Erros 4xx (token, número inválido, plano) falham de vez; timeout e 5xx voltam para a fila com espera crescente:

<?php

namespace App\Jobs;

use App\Models\NotificacaoPedido;
use App\Services\ApiBrasilWhatsApp;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use RuntimeException;
use Throwable;

class EnviarNotificacaoPedido implements ShouldQueue
{
    use Queueable; // Laravel 11+; no 10, use Dispatchable, InteractsWithQueue, Queueable e SerializesModels

    public int $tries = 4;   // 1 tentativa + 3 retentativas
    public int $timeout = 30;

    public function __construct(public NotificacaoPedido $notificacao) {}

    /** Espera entre tentativas, em segundos. */
    public function backoff(): array
    {
        return [30, 120, 600];
    }

    public function handle(ApiBrasilWhatsApp $api): void
    {
        if ($this->notificacao->status !== 'pendente') {
            return; // já enviada ou cancelada: idempotência
        }

        try {
            $resposta = $api->enviarTexto($this->notificacao->telefone, $this->notificacao->texto);
        } catch (RuntimeException $e) {
            if ($e->getCode() >= 400 && $e->getCode() < 500) {
                $this->fail($e); // 4xx não melhora com retry
                return;
            }
            throw $e; // timeout/5xx: a fila tenta de novo com backoff
        }

        $this->notificacao->update([
            'status'      => 'enviada',
            'mensagem_id' => data_get($resposta, 'data.id'), // confira o caminho na documentação
            'enviada_em'  => now(),
        ]);
    }

    public function failed(Throwable $e): void
    {
        $this->notificacao->update(['status' => 'falhou', 'erro' => $e->getMessage()]);
        EnviarFallbackSms::dispatch($this->notificacao); // o cliente não fica sem aviso
    }
}

Onde o job é despachado

Um observer no model Pedido é o ponto mais simples: sempre que o status muda, ele monta o texto, grava a notificação e despacha o job. Se o cliente não deu opt-in ou o status não tem template, nada acontece:

// app/Observers/PedidoObserver.php
public function updated(Pedido $pedido): void
{
    if (! $pedido->wasChanged('status') || ! $pedido->cliente->aceita_whatsapp) {
        return;
    }

    $texto = TemplatesPedido::para($pedido);
    if ($texto === null) {
        return; // status sem aviso (ex.: em separação)
    }

    $notificacao = NotificacaoPedido::firstOrCreate(
        ['pedido_id' => $pedido->id, 'evento' => $pedido->status],
        ['telefone' => $pedido->cliente->telefone_e164, 'texto' => $texto]
    );

    if ($notificacao->wasRecentlyCreated) {
        EnviarNotificacaoPedido::dispatch($notificacao)->onQueue('whatsapp');
    }
}

Rode o worker com php artisan queue:work –queue=whatsapp,default e monitore jobs falhos com queue:failed. Se já usa Horizon, basta incluir a fila whatsapp na configuração.

Templates de mensagem por status

Template é código, não configuração solta no banco, pelo menos no começo: fica versionado, testável e sem risco de alguém salvar uma mensagem com variável errada. Uma classe com match por status resolve, e o primeiro nome do cliente humaniza sem exagero:

<?php

namespace App\Support;

use App\Models\Pedido;

class TemplatesPedido
{
    public static function para(Pedido $p): ?string
    {
        $nome = explode(' ', trim($p->cliente->nome))[0];

        return match ($p->status) {
            'confirmado' => "Oi, {$nome}! Recebemos o pedido #{$p->numero}. Assim que o pagamento for aprovado, avisamos por aqui.",
            'pago'       => "Pagamento do pedido #{$p->numero} aprovado. Previsão de envio: {$p->previsao_envio}.",
            'enviado'    => "Pedido #{$p->numero} enviado! Rastreio: {$p->codigo_rastreio}. Acompanhe em {$p->link_rastreio}",
            'entregue'   => "Pedido #{$p->numero} entregue. Se algo não estiver certo, responda esta mensagem.",
            'pesquisa'   => "{$nome}, de 0 a 10, quanto você recomendaria nossa loja? Responda só com o número.",
            default      => null,
        };
    }
}

Regras que evitam atrito: uma informação nova por mensagem, sem oferta embutida em aviso transacional, e sempre um caminho de resposta (“responda esta mensagem”). O template de entrega é o último aviso operacional; a partir dele começa outra conversa, de pós-venda, recompra e indicação, que tem lógica própria e está bem descrita em vendas pelo WhatsApp: funil e follow-up. Não misture as duas no mesmo job.

Webhook de status e reenvio

A resposta do envio só diz que a mensagem foi aceita. O que aconteceu depois chega pelo webhook configurado no dispositivo: a APIBrasil faz um POST em JSON com o campo wook identificando o evento. Para status de mensagem, wook vem como MESSAGE_STATUS, com id (o mesmo do envio), status (SENT, RECEIVED, READ no caminho normal; FAILED, EXPIRED e outros em falha) e dateTime. Quando a sessão tem chave, o header x-webhook-signature traz um HMAC-SHA256 do corpo bruto, que vale conferir.

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

// app/Http/Controllers/WhatsAppWebhookController.php
public function __invoke(Request $request, string $segredo)
{
    abort_unless(hash_equals(config('services.apibrasil.webhook_secret'), $segredo), 404);

    $assinatura = $request->header('x-webhook-signature');
    if ($assinatura && ($chave = config('services.apibrasil.session_key'))) {
        $esperada = 'sha256=' . hash_hmac('sha256', $request->getContent(), $chave);
        abort_unless(hash_equals($esperada, $assinatura), 401);
    }

    $evento = $request->json()->all();

    if (($evento['wook'] ?? null) === 'MESSAGE_STATUS') {
        NotificacaoPedido::where('mensagem_id', $evento['id'] ?? '')
            ->update(['status_entrega' => $evento['status'] ?? null]);
    }

    if (($evento['wook'] ?? null) === 'RECEIVE_MESSAGE'
        && str_contains(mb_strtoupper($evento['content'] ?? ''), 'PARAR')) {
        Cliente::where('telefone_e164', $evento['from'] ?? '')->update(['aceita_whatsapp' => false]);
    }

    return response()->noContent(); // responda rápido; o processamento pesado vai para a fila
}

Com o status gravado, o reenvio vira uma regra de scheduler: notificação enviada que ficou em FAILED, ou sem RECEIVED depois de algumas horas, ganha um fallback por SMS. O job EnviarFallbackSms chama POST /sms/send/credits com number e message, só com o Bearer Token; a implementação em PHP está detalhada em como enviar SMS com PHP. Deduplique eventos pelo par (id, wook): o mesmo status pode chegar mais de uma vez.

// routes/console.php
Schedule::call(function () {
    NotificacaoPedido::where('status', 'enviada')
        ->where(fn ($q) => $q->whereNull('status_entrega')->orWhereIn('status_entrega', ['FAILED', 'EXPIRED']))
        ->where('enviada_em', '<', now()->subHours(2))
        ->whereNull('fallback_em')
        ->each(fn ($n) => EnviarFallbackSms::dispatch($n));
})->everyThirtyMinutes();

Opt-in e LGPD

Aviso de pedido é mensagem transacional, mas isso não dispensa consentimento claro. No checkout, uma opção desmarcada por padrão (“quero receber o andamento do pedido pelo WhatsApp”) grava aceita_whatsapp, a data e o texto exibido. A pesquisa de satisfação fica em um aceite separado se você pretende usá-la para marketing depois.

O número precisa ser do cliente e precisa ter WhatsApp. Confirmar o telefone no cadastro com um código evita notificar um estranho por um dígito trocado; o passo a passo de código de verificação OTP no Laravel se encaixa antes de qualquer envio. Guarde o telefone já normalizado (55 + DDD + número, só dígitos), que é o formato que a API espera.

Por fim, descadastro: a palavra PARAR recebida pelo webhook desliga o opt-in na hora, e o observer deixa de criar notificações. Mantenha os registros de envio pelo tempo necessário para auditoria e apague o resto; texto de mensagem com nome e endereço é dado pessoal como qualquer outro.

Perguntas frequentes

Preciso da API oficial do WhatsApp para enviar notificações de pedido?

A APIBrasil oferece planos com a API oficial e planos baseados em dispositivo conectado; nos dois casos a chamada usa Bearer Token e DeviceToken. A escolha depende de volume, exigências de conformidade e orçamento; consulte o catálogo na documentação. O código deste guia funciona com o endpoint de texto, independentemente do plano.

Como evito mandar a mesma mensagem duas vezes?

Com três travas: a chave única (pedido_id, evento) na tabela, o firstOrCreate no observer e a checagem de status pendente no início do job. Assim, nem um reprocessamento do webhook do gateway de pagamento nem um retry da fila geram mensagem duplicada.

O que acontece se o cliente não tiver WhatsApp?

O envio falha ou nunca recebe o status RECEIVED. O scheduler detecta isso e despacha o fallback por SMS, que chega em qualquer celular. Se quiser evitar a tentativa, confirme o número por OTP no cadastro e pergunte o canal preferido.

Posso usar Redis e Horizon em vez da fila em banco?

Sim, e é o recomendado quando o volume cresce. O job não muda; basta trocar QUEUE_CONNECTION para redis e incluir a fila whatsapp na configuração do Horizon, que ainda dá painel de monitoramento e métricas de falha.

Pesquisa de satisfação conta como marketing?

Uma pergunta sobre o pedido entregue ainda é parte da jornada, mas o uso posterior dos dados pode ser marketing. Trate a pesquisa com aceite próprio, limite a uma mensagem e respeite o PARAR como em qualquer outro envio.

Próximos passos

Com fila, templates e webhook funcionando, as notificações de pedido pelo WhatsApp passam a ser um fluxo auditável: cada aviso tem linha no banco, status de entrega e fallback. Se o seu backend for Node.js, a mesma arquitetura está em enviar WhatsApp com Node.js. Para começar, gere o token no painel da APIBrasil, conecte um dispositivo e envie o primeiro template para o seu próprio número.

Loading

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *