Como integrar a API do WhatsApp em PHP
A integração tem duas metades: a chamada que envia e o script que recebe. A primeira é cURL e cabe numa função. A segunda depende de ler o corpo da requisição antes que qualquer coisa encoste nele — e em PHP quase tudo encosta.
O que você precisa
- PHP 8.1 ou mais novo, com a extensão
curlhabilitada.hash_hmac,hash_equalserandom_bytessão do núcleo — não há dependência a instalar no caminho principal. - Uma chave de API (
hub_pk_...) com o escopomessages:send, criada em /painel/chaves. Ela aparece uma vez só; guarde em variável de ambiente, nunca no repositório. - O id do canal, de
GET /api/v1/channels. - O segredo do webhook (
whsec_...), devolvido uma única vez porPOST /api/v1/webhooks.
Enviar com cURL
O envio é um POST para /api/v1/channels/{id}/messages, com três headers e um corpo de dois níveis: to, type, e o objeto do tipo escolhido.
<?php
declare(strict_types=1);
const CRPRO_BASE = 'https://crprohub.com/api/v1';
final class ErroDeEnvio extends RuntimeException
{
public function __construct(
string $mensagem,
public readonly ?string $codigo,
public readonly int $httpStatus,
public readonly ?string $requestId,
) {
parent::__construct($mensagem);
}
}
function enviarTexto(string $canalId, string $para, string $texto, string $chave): string
{
$requestId = null;
$ch = curl_init(CRPRO_BASE . "/channels/{$canalId}/messages");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('CRPRO_API_KEY'),
'Idempotency-Key: ' . $chave,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(
['to' => $para, 'type' => 'text', 'text' => ['body' => $texto]],
JSON_THROW_ON_ERROR,
),
// O x-request-id so existe no cabecalho da resposta, e CURLOPT_RETURNTRANSFER
// devolve so o corpo. Sem este callback o erro chega sem o identificador
// que liga a falha ao registro do painel.
CURLOPT_HEADERFUNCTION => function ($ch, string $linha) use (&$requestId): int {
if (stripos($linha, 'x-request-id:') === 0) {
$requestId = trim(substr($linha, 13));
}
return strlen($linha);
},
]);
$corpoBruto = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// Timeout e erro de conexao nao dizem se a mensagem saiu. Status 0 marca
// esse caso para a retentativa tratar diferente de um 4xx.
if ($corpoBruto === false) {
throw new ErroDeEnvio('Falha de rede no envio', null, 0, null);
}
$corpo = json_decode($corpoBruto, true);
if ($status !== 202) {
throw new ErroDeEnvio(
$corpo['error']['message'] ?? 'Falha no envio',
$corpo['error']['code'] ?? null,
$status,
$requestId,
);
}
return $corpo['data']['message_id'];
}Repare no !== 202. Este endpoint não devolve 200: o 202 diz que a mensagem foi aceita e enfileirada para a Meta — não que chegou. Tratar essa resposta como entrega confirmada é o erro mais comum de quem integra WhatsApp pela primeira vez. O estado real chega depois, por webhook.
O type aceita text, image, audio, video, document, sticker, location, contacts, reaction, interactive e template. Cada um exige o objeto de mesmo nome no corpo.
JSON_THROW_ON_ERROR não é enfeite. Sem ele, json_encode devolve false em silêncio quando o texto não é UTF-8 válido — o caso clássico é um campo vindo de um banco em latin-1. O cURL então manda um corpo vazio, e você recebe 400 INVALID_BODY sem nenhuma pista de que o problema estava na codificação.A chave na retentativa
O header Idempotency-Key é obrigatório, e por isso a função acima recebe a chave por parâmetro em vez de gerar uma dentro. A diferença decide se a sua retentativa protege ou duplica:
<?php
declare(strict_types=1);
require __DIR__ . '/enviar.php';
$canalId = (string) getenv('CRPRO_CHANNEL_ID');
// A chave nasce FORA do laco. Gerar uma por tentativa faria cada retentativa
// virar um envio novo -- exatamente o que a idempotencia existe para evitar.
$chave = bin2hex(random_bytes(16));
for ($tentativa = 1; $tentativa <= 3; $tentativa++) {
try {
echo enviarTexto($canalId, '5521999999999', 'Olá', $chave), PHP_EOL;
break;
} catch (ErroDeEnvio $erro) {
// 4xx nao melhora com retentativa, com tres excecoes: 429, o status 0
// da falha de rede, e o 409 OPERATION_IN_PROGRESS -- que e exatamente a
// resposta a um retry cuja chave de idempotencia ainda esta em curso.
$valeRepetir = $erro->httpStatus === 0
|| $erro->httpStatus === 429
|| $erro->httpStatus === 409
|| $erro->httpStatus >= 500;
if (!$valeRepetir || $tentativa === 3) {
throw $erro;
}
usleep(2 ** $tentativa * 500_000);
}
}Com a mesma chave e o mesmo corpo, a segunda chamada devolve a resposta da primeira em vez de enviar de novo — é o que torna seguro repetir depois de um timeout, quando você não sabe se a requisição chegou. Um random_bytes dentro do laço destruiria essa garantia em silêncio: o cliente receberia a mesma mensagem três vezes e nenhum log acusaria erro.
O valor em si é livre — um UUID serve bem, e bin2hex(random_bytes(16)) resolve sem trazer uma biblioteca só para isso. O que importa é ser novo a cada envio distinto e estável dentro de uma mesma tentativa.
409 IDEMPOTENCY_KEY_REUSED. Não trate isso como falha transitória — é a API avisando que o seu código reaproveitou uma chave que já pertence a outro envio.Receber o webhook: php://input
Aqui está a armadilha. A assinatura cobre os bytes exatos que chegaram na conexão, e o único lugar onde eles existem em PHP é php://input. Reconstruir o corpo a partir de um array já convertido não funciona: json_encode(json_decode($corpo)) muda espaçamento, escapes de Unicode e ordem de chaves, e o HMAC do resultado não bate com nada.
<?php
declare(strict_types=1);
require __DIR__ . '/assinatura.php';
require __DIR__ . '/fila.php';
// php://input e o corpo BRUTO, exatamente como chegou na conexao. E o unico
// lugar de onde a assinatura pode ser conferida: $_POST fica vazio para
// application/json, e json_decode seguido de json_encode devolve outros bytes.
$rawBody = file_get_contents('php://input');
if (!assinaturaValida($_SERVER, $rawBody)) {
http_response_code(401);
exit;
}
$deliveryId = $_SERVER['HTTP_X_HUB_DELIVERY_ID'] ?? '';
$evento = json_decode($rawBody, true);
enfileirar($pdo, $deliveryId, $evento);
http_response_code(200);Não procure o payload em $_POST: ele só é preenchido para application/x-www-form-urlencoded e multipart/form-data. Com application/json ele vem vazio, e a conclusão errada — “o webhook não mandou nada” — custa uma tarde.
Num framework, a regra vira: pegue o corpo pelo método que devolve a string crua, nunca pelo que devolve o array. No Laravel é $request->getContent(), não $request->all(). Em qualquer coisa PSR-7 (Slim, Mezzio, Laminas) é (string) $request->getBody(), não $request->getParsedBody().
Verificar a assinatura
Toda entrega chega com X-Hub-Delivery-Id, X-Hub-Timestamp, X-Hub-Signature-Version e X-Hub-Signature-256, este último no formato sha256=HEX. O header de versão diz qual formato foi usado: v2 é o nativo, evohub-v1 é o de compatibilidade EvoHub.
No formato nativo, o HMAC não cobre só o corpo: cobre "{$timestamp}.{$deliveryId}.{$rawBody}". Amarrar a assinatura aos três é o que impede transplantar um corpo capturado para outra entrega. Assinar só o corpo neste formato falha sempre — e é o segundo erro mais caro deste guia.
<?php
declare(strict_types=1);
function assinaturaValida(array $server, string $rawBody): bool
{
// O PHP entrega header em $_SERVER com prefixo HTTP_, tudo em maiusculas e
// hifen virando underscore: X-Hub-Signature-256 -> HTTP_X_HUB_SIGNATURE_256.
$recebida = $server['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$timestamp = $server['HTTP_X_HUB_TIMESTAMP'] ?? '';
$deliveryId = $server['HTTP_X_HUB_DELIVERY_ID'] ?? '';
$recebida = str_starts_with($recebida, 'sha256=') ? substr($recebida, 7) : '';
// Header ausente ou malformado vira 401 aqui, e nao um TypeError la embaixo
// -- com strict_types, hash_equals(null, ...) derruba a rota em 500, e um
// 500 faz a entrega voltar na retentativa como se a culpa fosse do Hub.
if (preg_match('/^[0-9a-f]{64}$/', $recebida) !== 1) {
return false;
}
// Formato nativo: o HMAC cobre timestamp.delivery_id.corpo, nao so o corpo.
$assinado = "{$timestamp}.{$deliveryId}.{$rawBody}";
$esperada = hash_hmac('sha256', $assinado, (string) getenv('CRPRO_WEBHOOK_SECRET'));
// hash_equals compara em tempo constante. O valor vindo da requisicao vai
// sempre no SEGUNDO argumento -- e a ordem que a funcao documenta.
return hash_equals($esperada, $recebida);
}Três linhas desse trecho não são opcionais:
hash_equals, nunca==nem===. Comparação de string comum termina no primeiro byte diferente, e esse tempo vaza quantos bytes o atacante já acertou — dá para descobrir a assinatura correta por tentativa e erro.- A ordem dos argumentos: o valor conhecido primeiro, o que veio na requisição depois. Invertido, o tempo de execução passa a depender do tamanho do seu segredo em vez do tamanho do valor recebido.
- O teste
/^[0-9a-f]{64}$/antes de calcular o HMAC. Ele transforma header ausente, truncado ou com lixo num401limpo. Sem ele, umnullchegando aohash_equalssobstrict_typesviraTypeError, sua rota responde 500 e o Hub reenvia a entrega achando que a falha foi dele.
delivery_format: "evohub" assina somente o corpo cru: troque $assinado por $rawBody e o resto continua igual. Para saber qual dos dois usar, leia o X-Hub-Signature-Version da requisição — v2 é o nativo e evohub-v1 é o de compatibilidade. Os dois trechos lado a lado estão em webhooks. Em integração nova, prefira native.Responder rápido, processar depois
O script tem 10 segundos para responder 2xx. Salvar no banco, chamar uma IA e devolver uma resposta ao contato não cabe nesse orçamento — e estourar não é só lentidão: a entrega é reenviada, o seu código roda de novo, e o contato recebe a mesma resposta duas vezes.
<?php
declare(strict_types=1);
// A deduplicacao mora no banco, com indice unico em delivery_id. Um array
// estatico nao serve: no modelo classico do PHP (FPM ou mod_php) cada
// requisicao comeca com o estado zerado, entao a retentativa nunca encontraria
// o id que a primeira entrega guardou.
function enfileirar(PDO $pdo, string $deliveryId, array $evento): void
{
$insercao = $pdo->prepare(
'INSERT INTO webhook_recebidos (delivery_id, evento) VALUES (?, ?)
ON CONFLICT (delivery_id) DO NOTHING'
);
$insercao->execute([$deliveryId, json_encode($evento, JSON_THROW_ON_ERROR)]);
}
// No worker, num processo separado do que responde ao HTTP.
foreach ($pendentes as $linha) {
$evento = json_decode($linha['evento'], true);
match ($evento['event']) {
'message.received' => responderContato($evento['data']),
'message.status' => atualizarStatus($evento['data']),
default => null,
};
}A deduplicação por X-Hub-Delivery-Id é o que fecha essa porta: uma retentativa reenvia o mesmo id, e o ON CONFLICT DO NOTHING faz a segunda chegada não virar trabalho. Uma entrega que falha é reenviada até nove vezes, somando dez tentativas, e depois de 20 falhas consecutivas o endpoint pausa sozinho — reative no painel depois de corrigir a causa.
Se não houver worker nenhum na sua infraestrutura, o PHP-FPM tem uma saída — com um preço que vale conhecer antes:
// Ultimo recurso, so no PHP-FPM: devolve a resposta agora e continua rodando.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
processarAgora($evento);O fastcgi_finish_request() devolve a resposta e deixa o script continuar, mas o processo continua ocupando um worker do pool. Uma rajada de eventos esgota o pool e derruba o site inteiro junto — por isso é alternativa, não recomendação. E ele não existe fora do PHP-FPM: sob mod_php ou CLI server a função simplesmente não está definida, daí o function_exists.
No envelope normalized, o corpo traz id, event, channel_id, occurred_at e data. Os eventos de mensagem são message.received e message.status; correlacione o status com o message_id que o envio devolveu.
Onde as pessoas erram
- Framework que já converteu o corpo.
$request->all(),getParsedBody(),$_POST— qualquer um deles entrega um array, e reserializar esse array produz outros bytes. Sintoma: 401 em toda entrega, incluindo owebhook.test. - Assinar só o corpo num endpoint
v2. Mesmo sintoma do item acima, causa diferente — por isso vale registrar oX-Hub-Signature-Versionrecebido antes de suspeitar do segredo. - Procurar o header pelo nome original. Em
$_SERVERele vira$_SERVER['HTTP_X_HUB_SIGNATURE_256']: prefixo, maiúsculas, underscore.getallheaders()preserva o nome original, mas não existe em todo SAPI —$_SERVERexiste em todos. - Ler
php://inputdepois de algo já ter lido. Numa entrega JSON isso é seguro, mas se você montou a rota dentro de um controlador que já tocou no corpo, leia a string pelo próprio framework em vez de voltar ao stream. - Esperar 403 e receber 404. Um canal de outra organização responde
404, não403: 403 confirmaria que o recurso existe. Se o id parece certo e volta 404, confira a organização da chave antes do id.
A tabela completa de códigos está em erros, os limites de taxa em limites, e o contrato dos endpoints em referência de mensagens.