InícioDocumentação › API de WhatsApp em PHP

Como enviar mensagem de WhatsApp com PHP

Do token ao fluxo de duas vias em PHP 8: um cliente com a extensão cURL, o mesmo cliente escrito com Guzzle, envio de texto, imagem e botões, verificação de número e um receptor de webhook que devolve 200 antes de processar — em PHP puro e em Slim.

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.

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ódigoSignificaO que fazer em PHP
200Requisição aceitaGrave data['Id'] junto do seu registro e siga
400Corpo inválidoLoga o payload e corrige: campo obrigatório faltando, Phone fora do formato ou array virado objeto por falta de array_values()
401Token ausente ou inválidogetenv('ZAPON_TOKEN') devolvendo false monta o header token: vazio — falhe na inicialização, não no envio
404Endpoint inexistenteConfira a rota; um /chat/send/txt cai aqui
500Erro ao processarRepita 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
}
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.

Leia também

Escreva a integração hoje, conecte o número quando quiser.

14 dias grátis, sem cartão — a contagem só começa na primeira conexão. R$ 27 por mês por número, sem cobrança por mensagem.