Boa parte do software que roda no comércio brasileiro é PHP: o ERP interno, o painel do e-commerce, o módulo de cobrança que alguém escreveu em 2016 e nunca parou de funcionar. Ligar WhatsApp a esse código não exige reescrever nada — a API do zapon é REST com JSON, e a extensão cURL, presente em praticamente toda instalação, faz a chamada inteira.
Esta página vai do token ao fluxo de duas vias: envio de texto, imagem e botões, verificação de número, receptor de webhook e tratamento de erro por código HTTP. O cliente aparece em duas versões, cURL nativo e Guzzle, porque metade dos projetos PHP tem Composer e a outra metade não. Tudo roda em PHP 8.0 ou superior, sem depender de framework.
O que você precisa antes da primeira linha
O que preciso para chamar a API do zapon em PHP?
PHP 8.0 ou superior com as extensões curl, json e mbstring habilitadas, uma conta no zapon e um número conectado. Da conexão sai o token, que vai no header de toda chamada — um token por número. Não há pacote do zapon para instalar via Composer: a API é HTTP e o cliente cabe em quarenta linhas. Para o webhook, você precisa de uma URL pública que aceite POST.
- Extensões. Confirme com
php -m | grep -E 'curl|json|mbstring'. A curl faz a requisição, a json monta o corpo e a mbstring salva você do acento quebrado. - Conexão e token. No painel, crie a conexão e leia o QR Code com o WhatsApp do número (Aparelhos conectados → Conectar aparelho). Copie o
token: ele autentica as chamadas daquele número e só daquele. - Base da API.
https://api.zapon.dev, sempre por HTTPS.
Onde guardar o token num projeto PHP?
Em variável de ambiente do processo, lida com getenv(), ou no arquivo de configuração que já fica fora da raiz pública — um config.txt na pasta pública é leitura direta para qualquer um. Com Composer, o vlucas/phpdotenv resolve; sem ele, SetEnv no virtual host ou env[] no pool do PHP-FPM fazem o mesmo:
token fora do código e fora da pasta pública
# pool do PHP-FPM (www.conf)
env[ZAPON_TOKEN] = cole_aqui_o_token_da_conexao
# ou, para um script de linha de comando
ZAPON_TOKEN=cole_aqui_o_token php enviar.php
A autenticação é um header só, de nome token. Não é Authorization, não é Bearer e não há OAuth. Quem tem o token envia em nome daquele número: trate-o como a senha do banco — fora do Git, fora do log.
Primeiro envio de texto
Como enviar uma mensagem de WhatsApp com PHP?
Um POST para https://api.zapon.dev/chat/send/text com o header token, Content-Type: application/json e um corpo com dois campos: Phone, o número só com dígitos incluindo país e DDD, e Body, o texto. A resposta traz data.Id, o identificador da mensagem no WhatsApp — guarde-o junto do registro que originou o envio.
Em vez de espalhar curl_init pelo sistema, escreva uma classe de uma responsabilidade só. O resto da página usa o método chamar() dela:
Zapon.php — cliente com cURL nativo
<?php
declare(strict_types=1);
class ZaponErro extends RuntimeException
{
public function __construct(public readonly int $status, string $detalhe)
{
parent::__construct("zapon $status: $detalhe");
}
}
final class Zapon
{
private const BASE = 'https://api.zapon.dev';
public function __construct(private string $token) {}
public function chamar(string $rota, array $payload): array
{
$corpo = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$ch = curl_init(self::BASE . $rota);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => $corpo,
CURLOPT_HTTPHEADER => [
'token: ' . $this->token,
'Content-Type: application/json',
],
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 25, // sem isto o script pode ficar pendurado
]);
$resposta = curl_exec($ch);
$erroRede = curl_errno($ch) ? curl_error($ch) : null;
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($erroRede !== null) {
throw new ZaponErro(0, $erroRede); // 0 = nem chegou na API
}
$json = json_decode((string) $resposta, true); // erro de servidor pode não vir em JSON
if ($status !== 200 || ($json['success'] ?? false) !== true) {
throw new ZaponErro($status, $json['error'] ?? substr((string) $resposta, 0, 200));
}
return $json['data'] ?? [];
}
public function enviarTexto(string $phone, string $body): array
{
return $this->chamar('/chat/send/text', ['Phone' => $phone, 'Body' => $body]);
}
}
E a chamada, no ponto do sistema em que o pedido acabou de ser confirmado:
enviar.php — primeiro envio
<?php
require __DIR__ . '/Zapon.php';
$zapon = new Zapon(getenv('ZAPON_TOKEN') ?: throw new RuntimeException('ZAPON_TOKEN não definido'));
try {
$dados = $zapon->enviarTexto(
'5511999999999',
'Pedido 4821 confirmado. Assim que o pacote sair, mandamos o rastreio por aqui.'
);
echo 'mensagem enviada: ' . $dados['Id'] . PHP_EOL;
} catch (ZaponErro $e) {
error_log('falhou: ' . $e->status . ' ' . $e->getMessage());
}
Em caso de sucesso o envelope é sempre este: code, success e um data com o identificador e o horário do envio.
resposta da API
{
"code": 200,
"success": true,
"data": {
"Details": "Sent",
"Id": "90B2F8B13FAC8A9CF6B06E99C7834DC5",
"Timestamp": "2026-09-03T09:12:08-03:00"
}
}
Por que a mensagem com acento chega quebrada?
Porque json_encode() só aceita string em UTF-8 válido. Se o texto veio de um banco em latin1 ou de um CSV exportado do Excel, o json_encode() devolve false em silêncio — e sem a flag JSON_THROW_ON_ERROR você envia string vazia. É a armadilha número um de integração em PHP no Brasil, onde toda mensagem tem cedilha e til. Converta na fronteira, antes de montar o payload:
normalizando o texto antes de enviar
<?php
function paraUtf8(string $texto): string
{
if (mb_check_encoding($texto, 'UTF-8')) {
return $texto;
}
return mb_convert_encoding($texto, 'UTF-8', 'ISO-8859-1');
}
$zapon->enviarTexto($cliente['telefone'], paraUtf8($cliente['mensagem']));
Se o banco é MySQL, o outro lado do mesmo problema é a conexão: charset=utf8mb4 no DSN do PDO evita que o acento chegue torto na aplicação, e emoji só passa em utf8mb4 — a coluna em utf8 antigo corta a mensagem no primeiro emoji.
Vale a pena usar Guzzle em vez de cURL puro?
Vale se o projeto já tem Composer, porque o Guzzle cuida de retentativa via middleware, de pool de requisições e de exceções tipadas. Se o projeto é um script solto numa hospedagem compartilhada, cURL puro evita a dependência. O cliente é o mesmo em espírito — muda só o miolo do chamar():
ZaponGuzzle.php — mesma classe, outro motor
<?php
// composer require guzzlehttp/guzzle
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
final class ZaponGuzzle
{
private Client $http;
public function __construct(string $token)
{
$this->http = new Client([
'base_uri' => 'https://api.zapon.dev',
'timeout' => 25,
'http_errors' => false, // trate o status você mesmo, sem exceção em 4xx
'headers' => ['token' => $token],
]);
}
public function chamar(string $rota, array $payload): array
{
try {
$r = $this->http->post($rota, ['json' => $payload]);
} catch (TransferException $e) {
throw new ZaponErro(0, $e->getMessage());
}
$status = $r->getStatusCode();
$bruto = (string) $r->getBody();
$json = json_decode($bruto, true);
if ($status !== 200 || ($json['success'] ?? false) !== true) {
throw new ZaponErro($status, $json['error'] ?? substr($bruto, 0, 200));
}
return $json['data'] ?? [];
}
}
Repare no http_errors => false. Por padrão o Guzzle lança exceção em 4xx e 5xx, e quem não sabe disso descobre em produção, com um 401 derrubando o lote inteiro em vez de marcar uma linha como falha. Desligar e decidir você mesmo deixa o comportamento igual ao da versão com cURL.
Imagem e botões
Como enviar uma imagem pela API do zapon em PHP?
POST /chat/send/image com Phone, Image e, opcionalmente, Caption. O campo Image é o arquivo em base64 no formato data URI — data:image/jpeg;base64,.... Em PHP, isso é base64_encode(file_get_contents(...)) com o prefixo do tipo, sem biblioteca nenhuma.
enviando uma imagem
<?php
$caminho = __DIR__ . '/etiquetas/4821.jpg';
$tipo = mime_content_type($caminho); // não confie na extensão do arquivo
$imagem = 'data:' . $tipo . ';base64,' . base64_encode(file_get_contents($caminho));
$zapon->chamar('/chat/send/image', [
'Phone' => '5511999999999',
'Image' => $imagem,
'Caption' => 'Etiqueta do pedido 4821. Cole na caixa antes de postar.',
]);
Uma cautela de memória, específica de PHP: base64 cresce o arquivo em cerca de um terço e file_get_contents traz tudo para dentro do processo, então uma foto de 8 MB vira quase 11 MB de string e o memory_limit padrão de 128 MB estoura antes do que se imagina. Redimensione com GD ou Imagick e envie um arquivo por vez. Se a imagem está numa URL sua, baixe primeiro e converta o conteúdo — o campo espera os bytes, não o endereço.
Como mandar botões de resposta rápida?
O POST /chat/send/buttons aceita quantos botões você precisar — três ou quatro é o que se lê num toque. Cada um tem um id que você escolhe e um text curto — rótulo comprido é truncado pelo WhatsApp, então mantenha até 20 caracteres. O id é o que volta inteiro no webhook, e é ali que se carimba a chave do seu registro. Além da resposta rápida, o mesmo endpoint monta botão que abre um link ("type": "cta_url"), botão que abre o discador ("type": "cta_call") e botão que copia um código ("type": "copy") — e os tipos convivem na mesma mensagem. O toque em resposta rápida volta no webhook; os outros três agem no aparelho de quem recebe.
botões com o pedido carimbado no id
<?php
$botoes = [
['id' => 'ped4821|hoje', 'text' => 'Pode entregar'], // 13 caracteres
['id' => 'ped4821|amanha', 'text' => 'Prefiro amanhã'],
['id' => 'ped4821|humano', 'text' => 'Falar com alguém'],
];
$zapon->chamar('/chat/send/buttons', [
'Phone' => '5511999999999',
'Body' => 'Pedido 4821 sai para entrega hoje. Tem alguém para receber à tarde?',
'Footer' => 'Loja Exemplo',
'Buttons' => array_values($botoes), // array_values: veja o parágrafo abaixo
]);
O array_values() não é enfeite. PHP não distingue lista de dicionário: chaves 0, 1 e 2 viram [...] no JSON, mas se um array_filter() deixou só as chaves 0 e 2, o json_encode() gera {"0":...,"2":...} — objeto onde a API espera array, e a resposta é 400. Reindexe sempre que a lista passar por filtro ou unset(). Vale o mesmo para o array de números do /user/check.
Sem o carimbo no id, a resposta chega só com o telefone e você adivinha de qual pedido ela fala — problema garantido no cliente com dois pedidos abertos. Com ele, o webhook faz explode('|', ...) e vai direto ao registro. Para mais de três opções, o caminho é o /chat/send/list.
Verificar o número antes de enviar
Como saber se um número tem WhatsApp antes de disparar?
POST /user/check recebe um array em Phone e devolve, para cada número, o campo IsInWhatsapp. Rodar isso antes de um lote evita gastar chamada com telefone fixo e cadastro velho — e uma sequência de envios para números inexistentes é justamente o padrão que aumenta o risco do seu número.
filtrando a lista antes do disparo
<?php
$candidatos = ['5511999999999', '5511888888888', '551133334444'];
$dados = $zapon->chamar('/user/check', ['Phone' => array_values($candidatos)]);
$validos = [];
$fora = [];
foreach ($dados['Users'] as $u) {
($u['IsInWhatsapp'] ? $validos : $fora)[] = $u['Query'];
}
print_r($validos); // ["5511999999999"]
marcarCadastroSemWhatsapp($fora); // corrija a origem, não só o disparo de hoje
Grave o resultado no cadastro — uma coluna tem_whatsapp com a data da verificação basta — em vez de reconsultar a cada campanha. E trate $fora como tarefa de dados: número sem WhatsApp quase sempre é cadastro com o nono dígito faltando, e isso se corrige uma vez.
Receber mensagens: o webhook
Como receber em PHP as respostas que chegam no WhatsApp?
Cadastre no painel, por conexão, uma URL pública e escolha os eventos. Cada evento vira um POST para essa URL com a mensagem inteira no corpo — não há segunda chamada para buscar conteúdo. Em PHP, o script deve devolver 200 e encerrar a resposta antes de processar, o que se faz com fastcgi_finish_request() ou empilhando o evento numa fila.
O receptor precisa aceitar duas formas de corpo: JSON puro, que o PHP não coloca em $_POST, e formulário codificado com o evento dentro do campo jsonData. Quem só lê $_POST vê um array vazio e conclui que "o webhook não chega" — ele chega, o corpo é que está em php://input.
webhook.php — PHP puro, sem framework
<?php
declare(strict_types=1);
require __DIR__ . '/Zapon.php';
// 1) Responder primeiro. Nada de trabalho pesado antes desta linha.
http_response_code(200);
header('Content-Type: text/plain');
echo 'ok';
ignore_user_abort(true);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // devolve a resposta e continua rodando (PHP-FPM)
}
// 2) Só agora o processamento.
$evento = leEvento();
if (($evento['type'] ?? '') !== 'Message') {
exit; // ignore o que o seu fluxo não usa
}
$info = $evento['event']['Info'] ?? [];
if (($info['IsFromMe'] ?? false) || ($info['IsGroup'] ?? false)) {
exit; // o que você mesmo enviou volta como evento
}
if (jaProcessado($info['ID'] ?? '')) {
exit; // reentrega acontece: guarde o ID no banco
}
$telefone = explode('@', (string) ($info['Chat' ] ?? ''))[0];
$msg = $evento['event']['Message'] ?? [];
$botao = $msg['buttonsResponseMessage']['selectedButtonID'] ?? '';
$texto = trim($msg['conversation'] ?? $msg['extendedTextMessage']['text'] ?? '');
$zapon = new Zapon(getenv('ZAPON_TOKEN'));
if ($botao !== '') {
[$pedido, $acao] = array_pad(explode('|', $botao, 2), 2, '');
registrarEscolha($pedido, $acao);
$zapon->enviarTexto($telefone, $acao === 'hoje'
? 'Combinado. A entrega sai hoje à tarde.'
: 'Certo, remarcamos para amanhã.');
exit;
}
filaDeAtendimento($telefone, $texto); // o que a regra não entende é assunto de gente
function leEvento(): array
{
if (isset($_POST['jsonData'])) { // corpo como formulário
return json_decode((string) $_POST['jsonData'], true) ?? [];
}
$bruto = file_get_contents('php://input'); // corpo como JSON
return json_decode((string) $bruto, true) ?? [];
}
Se o projeto usa Slim, a mesma lógica cabe numa rota, com a vantagem de já ter o corpo analisado e o roteamento pronto:
a mesma rota em Slim 4
<?php
// composer require slim/slim slim/psr7
use Psr\Http\Message\ResponseInterface as Res;
use Psr\Http\Message\ServerRequestInterface as Req;
$app = Slim\Factory\AppFactory::create();
$app->addBodyParsingMiddleware(); // entende JSON e formulário
$app->post('/whatsapp/eventos', function (Req $req, Res $res): Res {
$corpo = (array) $req->getParsedBody();
$evento = isset($corpo['jsonData'])
? json_decode((string) $corpo['jsonData'], true)
: $corpo;
enfileirar($evento); // grave e saia: o trabalho é do worker
return $res->withStatus(200);
});
$app->run();
Qual é o formato do evento de mensagem recebida?
O corpo traz o tipo do evento e a mensagem completa. Assim chega quem respondeu por escrito:
payload do evento Message
{
"type": "Message",
"event": {
"Info": {
"ID": "3EB0C767D26A1B5F7C83",
"Chat": "[email protected]",
"Sender": "[email protected]",
"IsFromMe": false,
"IsGroup": false,
"PushName": "Ana Souza",
"Timestamp": "2026-09-03T18:04:22-03:00"
},
"Message": { "conversation": "pode entregar sim" }
}
}
// quando ele toca num botão, muda só o bloco Message:
"Message": { "buttonsResponseMessage": {
"selectedButtonID": "ped4821|hoje",
"Response": { "SelectedDisplayText": "Pode entregar" }
} }
Por que responder 200 antes de processar?
Porque a entrega do evento tem janela curta, e o modelo de execução do PHP funciona contra você aqui: um script que consulta banco, chama outra API e só então imprime a resposta transforma cada evento numa espera longa. Em PHP-FPM, fastcgi_finish_request() resolve — a resposta sai e o processo continua trabalhando. Em mod_php essa função não existe: grave o evento cru numa tabela, devolva 200 e deixe um cron ou um worker processar. Vale ainda registrar quem enviou o POST: as entregas chegam com o cabeçalho User-Agent: zapon-webhook/1, o que ajuda a separar tráfego real de varredura na sua porta.
Tratamento de erro por código HTTP
O que fazer em cada erro da API?
400 é corpo inválido e você conserta no código, não repetindo. 401 é token errado ou de outra conexão. 404 é caminho digitado errado. 500 costuma ser conexão fora do ar, e é o único caso em que repetir faz sentido — com espera crescente. Repetir um 400 ou um 401 só gasta chamada.
| Código | Significa | O que fazer em PHP |
|---|
200 | Requisição aceita | Grave data['Id'] junto do seu registro e siga |
400 | Corpo inválido | Loga o payload e corrige: campo obrigatório faltando, Phone fora do formato ou array virado objeto por falta de array_values() |
401 | Token ausente ou inválido | getenv('ZAPON_TOKEN') devolvendo false monta o header token: vazio — falhe na inicialização, não no envio |
404 | Endpoint inexistente | Confira a rota; um /chat/send/txt cai aqui |
500 | Erro ao processar | Repita com espera crescente e, se persistir, veja o estado da conexão no painel |
Em código, isso vira uma função que decide o que merece nova tentativa:
repetição seletiva com espera crescente
<?php
function chamarComRetentativa(Zapon $zapon, string $rota, array $payload, int $tentativas = 3): array
{
for ($i = 1; ; $i++) {
try {
return $zapon->chamar($rota, $payload);
} catch (ZaponErro $e) {
$temporario = $e->status === 0 || $e->status >= 500;
if (!$temporario || $i >= $tentativas) {
throw $e; // 400 e 401 não melhoram com insistência
}
sleep(2 ** ($i - 1)); // 1s, 2s, 4s
}
}
}
Um detalhe que economiza plantão: em worker de linha de comando, deixe a falha definitiva virar registro visível — uma linha numa tabela de pendências — em vez de um error_log() que ninguém lê. Mensagem que não saiu precisa virar tarefa de alguém.
Ritmo de envio e preservação do número
O lote em PHP costuma nascer como um foreach sobre um SELECT, dentro de uma requisição web. Aí os dois problemas aparecem juntos: o max_execution_time mata o script no meio e o disparo sai em rajada. Rode lotes pela linha de comando, onde o limite de tempo não se aplica por padrão, e coloque intervalo entre os envios:
lote.php — um por vez, com intervalo (rode via CLI)
<?php
if (PHP_SAPI !== 'cli') {
exit('este script é para linha de comando');
}
require __DIR__ . '/Zapon.php';
$zapon = new Zapon(getenv('ZAPON_TOKEN'));
foreach (pendentesDeHoje() as $cliente) {
if ($cliente['opt_out']) {
continue; // quem pediu para sair, sai de todas as rotinas
}
try {
$dados = $zapon->enviarTexto($cliente['telefone'], textoPara($cliente));
registrarEnvio($cliente['id'], $dados['Id']); // guardar o Id evita mandar duas vezes
} catch (ZaponErro $e) {
registrarFalha($cliente['id'], $e->getMessage());
}
usleep(random_int(3_000_000, 7_000_000)); // 3s a 7s, com variação
}
- Fale com quem espera. Mensagem sobre um pedido que a pessoa fez é esperada; lista comprada é denúncia esperando acontecer, e denúncia é o que pesa.
- Intervalo com variação. Um
sleep(2) exato mil vezes é tão artificial quanto o disparo em rajada. O random_int acima custa nada. - Aqueça número novo. Comece com poucos envios por dia e aumente ao longo de semanas. Número recém-criado que estreia em volume chama atenção.
- Descadastro que funciona. Guarde o
opt_out no cadastro e faça toda rotina consultá-lo antes de enviar. O pedido chega em qualquer palavra, então alguém precisa olhar a fila de exceções.
Sobre bloqueio de número, sem promessa mágica. Nenhuma API — nem a oficial da Meta — impede que o WhatsApp aja contra um número denunciado. O risco não se elimina, se administra: falar com quem espera, manter volume e ritmo compatíveis com o seu negócio, responder quem responde e parar de enviar para quem pediu para sair. Quem promete que o número nunca será bloqueado não está sendo honesto.
Sobre custo, o cálculo em PHP é o mesmo de qualquer linguagem: R$ 27 por mês por número conectado, sem cobrança por mensagem, com cota de 300 mensagens por dia por número conectado — passou do teto, a API devolve HTTP 429, e o seu código em PHP trata isso como qualquer outro status de erro. O teste é de 14 dias sem cartão e só começa a contar na primeira conexão — dá para escrever a integração inteira, com testes, antes de gastar o primeiro dia.
Perguntas frequentes
Funciona em hospedagem compartilhada?
Funciona, desde que a extensão curl esteja habilitada — o que é o caso na maioria dos planos. Atenção a dois pontos: o max_execution_time, que atrapalha lotes longos e por isso o exemplo roda por CLI ou cron, e a ausência de fastcgi_finish_request() em mod_php, contornada gravando o evento e processando depois.
Dá para usar sem Composer?
Dá. A classe com cURL nativo desta página não depende de nada além das extensões do próprio PHP, e é ela que roda em projetos legados sem autoload. O Guzzle entra quando o projeto já tem Composer e você quer middleware de retentativa e requisições em pool.
E se eu preferir file_get_contents com stream context em vez de cURL?
Funciona, mas custa caro na hora do erro: sem ignore_errors => true no contexto, uma resposta 401 vira warning e false, sem corpo para você ler a mensagem da API, e o status só aparece se você garimpar $http_response_header. Com cURL o status vem limpo em curl_getinfo() — motivo suficiente para preferi-lo.
Como integro isso no Laravel?
Pelo Http: Http::withHeaders(['token' => config('zapon.token')])->timeout(25)->post('https://api.zapon.dev/chat/send/text', [...]). Para o webhook, exclua a rota do CSRF em VerifyCsrfToken — sem isso o POST do zapon toma 419 — e despache o processamento com dispatch(). Em Symfony, o equivalente é o HttpClientInterface mais o Messenger.
Preciso desligar a verificação de SSL para funcionar?
Não, e nunca desligue. CURLOPT_SSL_VERIFYPEER => false é a "solução" que circula em fórum e que abre a porta para interceptação do seu token. Se o cURL reclama do certificado, o problema é o pacote de certificados da máquina: aponte o curl.cainfo do php.ini para um cacert.pem atualizado.
Como testo o webhook sem publicar o serviço?
Suba o servidor embutido com php -S localhost:8000 webhook.php, exponha a porta com um túnel HTTP e cadastre a URL do túnel na conexão. Para testar só a sua regra, faça um POST no seu próprio endpoint com o payload de exemplo desta página. Lembre que o servidor embutido atende uma requisição por vez e não tem fastcgi_finish_request(): ele confere a lógica, não o comportamento sob carga.
Uma conexão só atende vários números?
Não: um token é um número. Para operar vários, instancie a classe uma vez por conexão, com o token correspondente — o construtor já recebe o token justamente para isso. Cada número conectado custa R$ 27 por mês, sem cobrança por mensagem, e tem a sua própria cota de 300 mensagens por dia.