API de WhatsApp em PHP: cURL, Guzzle e webhook em qualquer hospedagem

Para usar a API de WhatsApp em PHP você não precisa de framework nem de servidor dedicado: uma função com cURL envia a mensagem e um arquivo que lê php://input recebe o webhook. É o caminho para sistemas legados e hospedagens compartilhadas, onde não dá para instalar worker nem abrir porta.

O cenário: sistema antigo, hospedagem simples

Muito sistema de gestão, loja e área do cliente no Brasil ainda roda em PHP procedural, em uma hospedagem compartilhada com cPanel, sem acesso root. Reescrever para colocar WhatsApp não é opção. A boa notícia é que a API de WhatsApp da D-API é REST: não exige biblioteca, processo em segundo plano nem conexão permanente com o WhatsApp do lado do seu servidor. Quem mantém a conexão com o número é a D-API; o seu PHP só faz requisições HTTPS e recebe requisições HTTPS.

O que o servidor precisa ter:

  • extensão cURL ativa, presente em praticamente toda hospedagem;
  • saída para https://api.d-api.cloud na porta 443;
  • um endereço público com HTTPS para o arquivo do webhook;
  • acesso ao cron do painel, se você quiser processar eventos em lote.

Enviar mensagem com cURL puro

A função abaixo cabe em um arquivo dapi.php e pode ser incluída em qualquer tela do sistema. Ela define tempo máximo de conexão e de resposta, trata falha de rede e converte a resposta de erro da API, que chega como {"success": false, "error": "...", "statusCode": 400}, em exceção.

<?php
// dapi.php
define('DAPI_API_KEY', getenv('DAPI_API_KEY'));

function dapi_post(string $caminho, array $payload): array
{
    $ch = curl_init('https://api.d-api.cloud' . $caminho);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 3,   // segundos para abrir a conexão
        CURLOPT_TIMEOUT        => 10,  // segundos para a requisição inteira
        CURLOPT_HTTPHEADER     => [
            'Authorization: ' . DAPI_API_KEY,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode($payload),
    ]);

    $corpo = curl_exec($ch);
    if ($corpo === false) {
        $erro = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('Falha de rede com a D-API: ' . $erro);
    }

    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $json = json_decode($corpo, true) ?: [];

    if ($status >= 400) {
        throw new RuntimeException($json['error'] ?? 'HTTP ' . $status, $status);
    }
    return $json;
}

// uso
dapi_post('/api/v1/messages/send/text', [
    'sessionId' => 'loja',
    'to'        => '5511999999999',
    'text'      => 'Recebemos seu pedido 4812. Obrigado!',
]);

Trocando o caminho, a mesma função envia imagem com legenda: use /api/v1/messages/send/image com os campos image (URL pública ou base64) e caption. O timeout é o que impede que a página do sistema fique carregando se a rede oscilar. Em hospedagem compartilhada o PHP costuma ter limite de execução baixo, e uma chamada sem timeout pode derrubar a requisição do usuário inteira.

A mesma chamada com Guzzle

Se o projeto já usa Composer, o Guzzle deixa o código mais legível e separa com clareza erro de rede de erro devolvido pela API.

<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\ConnectException;

$dapi = new Client([
    'base_uri'        => 'https://api.d-api.cloud',
    'timeout'         => 10,
    'connect_timeout' => 3,
    'headers'         => ['Authorization' => getenv('DAPI_API_KEY')],
]);

try {
    $dapi->post('/api/v1/messages/send/text', [
        'json' => ['sessionId' => 'loja', 'to' => '5511999999999', 'text' => 'Pedido enviado.'],
    ]);
} catch (BadResponseException $e) {
    $erro = json_decode((string) $e->getResponse()->getBody(), true);
    error_log('D-API recusou: ' . ($erro['error'] ?? $e->getMessage()));
} catch (ConnectException $e) {
    error_log('D-API inacessível ou timeout: ' . $e->getMessage());
}

BadResponseException cobre respostas 4xx e 5xx. ConnectException cobre DNS, conexão recusada e tempo esgotado. Na primeira, reveja o payload; na segunda, vale tentar de novo mais tarde.

Receber o webhook com php://input

O webhook chega como POST com corpo JSON. Em PHP isso significa ler php://input: o $_POST vem vazio porque não é um formulário. Em hospedagem compartilhada, o jeito mais seguro de lidar com eventos é gravar primeiro e processar depois, porque o arquivo precisa responder rápido e o PHP-FPM do provedor costuma ter poucos processos disponíveis.

<?php
// webhook-whatsapp.php  (URL cadastrada: https://seusite.com.br/webhook-whatsapp.php?token=SEGREDO)
require __DIR__ . '/config.php';

if (!hash_equals(WEBHOOK_TOKEN, $_GET['token'] ?? '')) {
    http_response_code(401);
    exit;
}

$bruto  = file_get_contents('php://input');
$evento = json_decode($bruto, true);
if (!is_array($evento) || !isset($evento['event'])) {
    http_response_code(400);
    exit;
}

// chave única evita gravar duas vezes o mesmo evento reenviado
$chave = $evento['data']['id'] ?? $evento['traceId'];
$pdo->prepare(
    'INSERT IGNORE INTO whatsapp_eventos (chave, evento, sessao, payload, criado_em)
     VALUES (?, ?, ?, ?, NOW())'
)->execute([$chave, $evento['event'], $evento['sessionId'], $bruto]);

http_response_code(200);
echo 'ok';

A tabela whatsapp_eventos precisa de índice único em chave; é ele que faz o INSERT IGNORE descartar repetições. O webhook não tem assinatura, então o token na query string é a proteção do endpoint. Um script agendado no cron, rodando a cada minuto, lê os eventos pendentes e faz o trabalho mais demorado: responder o cliente, atualizar o pedido, avisar o financeiro.

Nesse script, marque cada linha como processada só depois de concluir o trabalho e limite quantas linhas ele pega por execução, por exemplo 100. Assim uma fila acumulada não estoura o tempo máximo de execução do PHP configurado pelo provedor.

Armadilhas comuns em servidor compartilhado

SintomaCausa provávelO que fazer
Eventos param de chegar depois de uma mudança no siteRegra do .htaccess redirecionando o arquivo e devolvendo 404Exclua o caminho do webhook das regras de rewrite. Respostas 404 e 410 encerram as tentativas
Webhook recebe 403 sem motivo aparenteFirewall de aplicação do provedor barrando POST com JSONPeça ao suporte uma exceção para aquele arquivo
Mesma mensagem processada duas vezesResposta lenta fez a entrega ser repetidaGravar e responder, processar no cron, índice único
Envio falha com erro de certificadoPacote de CAs desatualizado no servidorAtualizar o PHP ou o bundle de certificados, nunca desligar a verificação SSL

Onde isso se encaixa no seu sistema

Os pontos de entrada mais comuns em sistemas PHP antigos são o fechamento do pedido, a emissão do boleto e o agendamento. Uma chamada a dapi_post logo depois do INSERT que já existe resolve a maior parte. Para régua de cobrança, veja o guia de cobrança pelo WhatsApp. Se o seu projeto está em Laravel, o guia de Laravel mostra a versão com filas e notificações. Os detalhes de cada evento recebido estão em webhook de WhatsApp.

A conexão usada nesses exemplos é a não oficial, pareada por QR Code, que não exige aprovação de template para mandar a primeira mensagem. As regras e cuidados desse modelo estão em API não oficial de WhatsApp, e os planos, cobrados por conexão, em preços.

Perguntas frequentes

Funciona em hospedagem compartilhada?

Na maioria dos casos, sim. O envio precisa só da extensão cURL e de saída liberada para HTTPS. O webhook é um arquivo PHP comum acessível pela internet. Confirme com o provedor se requisições de saída não são bloqueadas.

Preciso do Composer?

Não. O exemplo com cURL usa apenas funções nativas do PHP e cabe em um único arquivo incluído com require. O Composer só entra se você quiser usar o Guzzle ou já usa em outras partes do projeto.

Qual versão mínima do PHP?

Os exemplos desta página rodam em PHP 7.4 ou superior. Se o sistema ainda estiver em uma versão mais antiga, a lógica é a mesma, mas será preciso remover as declarações de tipo das funções.

Meu webhook recebe o POST mas $_POST vem vazio. Por quê?

Porque o corpo chega como JSON, e o PHP só preenche $_POST para formulários. Leia o corpo bruto com file_get_contents em php://input e decodifique com json_decode.

Posso usar o mesmo código em um Laravel?

Pode, mas o Laravel já tem o Http client, filas e notificações que deixam a integração mais organizada. Para esse caso existe um guia específico de Laravel.

Teste a API de WhatsApp da D-API

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