InícioDocumentação › API de WhatsApp com cURL

Como enviar mensagem de WhatsApp com cURL

O caminho mais curto entre o token e a primeira mensagem: comandos completos de curl para enviar texto, imagem e botões, verificar número, ler o código HTTP de resposta e receber o webhook num túnel para a sua máquina — tudo antes de escrever a primeira linha de código.

Antes de abrir o editor, vale gastar dez minutos no terminal. Um curl que devolve 200 prova três coisas de uma vez: o token está certo, o número está conectado e o corpo tem o formato que a API espera. Quando o mesmo envio falha depois dentro do seu código, você já sabe que o problema não é a API — é a serialização, o header ou a variável de ambiente que não chegou.

Esta página é o roteiro dessa checagem, e serve também como referência de HTTP puro para quem integra de uma linguagem sem tutorial aqui. Todo comando é colável, e o que muda entre sistemas está marcado. A base é https://api.zapon.dev e a autenticação é o header token.

O que você precisa antes do primeiro comando

O que preciso para testar a API do zapon no terminal?

O curl, que já vem instalado em Linux, macOS e Windows 10 ou superior, 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. O jq é opcional, mas facilita muito: ele monta JSON com escape correto e lê a resposta sem você contar chaves.

Como não deixar o token gravado no histórico do shell?

Colar o token direto no comando o deixa no ~/.bash_history para sempre, e histórico vaza em captura de tela, sessão compartilhada e backup. Exporte a variável com um espaço antes do comando, ou leia de um arquivo com permissão restrita:

token fora do histórico
# o espaço inicial evita o histórico (com HISTCONTROL=ignorespace no bash)
 export ZAPON_TOKEN='cole_aqui_o_token_da_conexao'

# ou, melhor, num arquivo só seu
printf '%s' 'cole_aqui_o_token_da_conexao' > ~/.zapon-token
chmod 600 ~/.zapon-token
export ZAPON_TOKEN="$(cat ~/.zapon-token)"

A autenticação é um header só, de nome token. Não é Authorization, não é Bearer e não há OAuth. Por isso a flag -u do curl não tem uso aqui: o token vai em -H "token: ...", e mais nada.

Primeiro envio de texto

Como enviar uma mensagem de WhatsApp com curl?

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.

primeiro envio
curl -sS -X POST https://api.zapon.dev/chat/send/text \
  -H "token: $ZAPON_TOKEN" \
  -H "Content-Type: application/json" \
  --connect-timeout 10 --max-time 25 \
  -d '{
    "Phone": "5511999999999",
    "Body": "Pedido 4821 confirmado. Assim que o pacote sair, mandamos o rastreio por aqui."
  }'

O par -sS vale a explicação: -s cala a barra de progresso, que polui a saída em comandos encadeados, e -S devolve a mensagem de erro que o -s teria escondido — usar só -s é desligar o alarme de incêndio. Os dois prazos também são deliberados: --connect-timeout limita a conexão e --max-time limita a chamada inteira, então nenhum comando fica pendurado dentro de um script.

A resposta de sucesso tem sempre este formato:

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 apóstrofo quebra o comando?

Porque o corpo depois de -d está entre aspas simples, e um apóstrofo no texto fecha a string no meio: d'água derruba o comando com erro de sintaxe do shell, não da API. A mesma armadilha aparece com $, que dentro de aspas duplas vira expansão de variável. A saída limpa é não montar JSON à mão — o jq escapa tudo, inclusive quebra de linha e emoji.

montando o corpo com jq, sem medo de aspas
TEXTO="Seu pedido d'água mineral saiu para entrega. Custo: R\$ 0,00 no frete."

jq -n --arg p "5511999999999" --arg b "$TEXTO" \
   '{Phone: $p, Body: $b}' \
| curl -sS -X POST https://api.zapon.dev/chat/send/text \
    -H "token: $ZAPON_TOKEN" \
    -H "Content-Type: application/json" \
    --data-binary @-

O --data-binary @- lê o corpo da entrada padrão. Prefira-o a -d @-: o -d remove quebras de linha, o que é inofensivo em JSON compacto mas destrói uma mensagem de várias linhas carregada de um arquivo. E guardar o corpo em disco resolve de vez o problema de aspas:

corpo em arquivo — funciona igual em qualquer sistema
cat > corpo.json <<'JSON'
{
  "Phone": "5511999999999",
  "Body": "Bom dia! Sua consulta está confirmada para amanhã às 14h."
}
JSON

curl -sS -X POST https://api.zapon.dev/chat/send/text \
  -H "token: $ZAPON_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @corpo.json

As aspas simples no delimitador <<'JSON' impedem que o shell expanda qualquer $ dentro do bloco — detalhe que evita ver R$ 89,90 chegar como R no WhatsApp. Salve o arquivo em UTF-8: um corpo.json em ISO-8859-1 manda acento quebrado, e a API não tem como adivinhar.

Como ver o código HTTP e o corpo ao mesmo tempo?

Por padrão o curl imprime só o corpo e devolve 0 como código de saída mesmo em 401 — o que faz um script achar que deu tudo certo. A flag -w resolve, e é a que mais vale a pena decorar:

corpo e status na mesma saída
curl -sS -X POST https://api.zapon.dev/chat/send/text \
  -H "token: $ZAPON_TOKEN" -H "Content-Type: application/json" \
  -d '{"Phone":"5511999999999","Body":"teste"}' \
  -w '\n--- HTTP %{http_code} em %{time_total}s\n'

Para diagnosticar a fundo, troque por -i (headers da resposta) ou -v (a conversa inteira, inclusive o header token — nunca cole a saída de um -v num chamado de suporte sem apagar essa linha). E há a dupla --fail e --fail-with-body: as duas fazem o curl sair com código diferente de zero em 4xx e 5xx, mas só a segunda imprime a explicação. Em script, use sempre a segunda.

Imagem e botões

Como enviar uma imagem pela API do zapon com curl?

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,.... No terminal, isso é o comando base64 mais um jq para montar o JSON, porque a string codificada é grande demais para caber numa linha de comando confortável.

enviando uma imagem
# Linux: -w0 evita quebra de linha a cada 76 caracteres
B64=$(base64 -w0 etiqueta-4821.jpg)

# macOS e BSD: a flag -w não existe; lá o padrão já é linha única
# B64=$(base64 -i etiqueta-4821.jpg)

jq -n --arg img "data:image/jpeg;base64,$B64" \
   '{Phone: "5511999999999", Image: $img, Caption: "Etiqueta do pedido 4821."}' \
| curl -sS -X POST https://api.zapon.dev/chat/send/image \
    -H "token: $ZAPON_TOKEN" -H "Content-Type: application/json" \
    --data-binary @- -w '\nHTTP %{http_code}\n'

A diferença entre o base64 do GNU e o do BSD é a pegadinha número um deste envio: no Linux, sem -w0, a saída vem quebrada em várias linhas e o data URI fica inválido; no macOS, a flag -w0 nem existe. E lembre que base64 cresce o arquivo em cerca de um terço — uma foto de 5 MB vira quase 7 MB de corpo, então redimensione antes de testar com a imagem original da câmera.

Como mandar botões de resposta rápida?

O POST /chat/send/buttons vai com quantos botões você precisar — três ou quatro é o que se lê num toque —, cada um com um id definido por você e um text de no máximo 20 caracteres — acima disso o WhatsApp trunca o rótulo. O id é o que volta inteiro no webhook, então carimbe nele 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.

botoes.json — corpo pronto para colar
cat > botoes.json <<'JSON'
{
  "Phone": "5511999999999",
  "Body": "Pedido 4821 sai para entrega hoje. Tem alguém para receber à tarde?",
  "Footer": "Loja Exemplo",
  "Buttons": [
    { "id": "ped4821|hoje",   "text": "Pode entregar" },
    { "id": "ped4821|amanha", "text": "Prefiro amanhã" },
    { "id": "ped4821|humano", "text": "Falar com alguém" }
  ]
}
JSON

curl -sS -X POST https://api.zapon.dev/chat/send/buttons \
  -H "token: $ZAPON_TOKEN" -H "Content-Type: application/json" \
  --data-binary @botoes.json -w '\nHTTP %{http_code}\n'

Antes de acusar a API de um 400, valide o arquivo com jq . botoes.json: vírgula sobrando no fim de um array é o erro mais comum em corpo escrito à mão, e o jq aponta a linha exata. Sem o carimbo no id, a resposta chega só com o telefone; com ele, o seu código separa por | 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. No terminal, isso vira um comando só, e o jq filtra a saída direto para um arquivo de números válidos.

verificando e separando a lista
curl -sS -X POST https://api.zapon.dev/user/check \
  -H "token: $ZAPON_TOKEN" -H "Content-Type: application/json" \
  -d '{"Phone":["5511999999999","5511888888888","551133334444"]}' \
| tee resultado.json \
| jq -r '.data.Users[] | select(.IsInWhatsapp) | .Query' > validos.txt

# quem ficou de fora, para você corrigir o cadastro
jq -r '.data.Users[] | select(.IsInWhatsapp | not) | .Query' resultado.json > sem-whatsapp.txt

O tee guarda a resposta bruta enquanto o jq filtra, o que evita repetir a chamada quando você percebe que precisava da outra metade da lista. Rodar essa verificação antes de qualquer disparo 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.

Receber mensagens: testando o webhook

Como testar o webhook do WhatsApp sem publicar nada?

Suba um receptor na sua máquina, exponha a porta com um túnel HTTP — qualquer serviço que dê uma URL pública apontando para a sua porta local serve — e cadastre essa URL na conexão, escolhendo os eventos. Cada evento vira um POST com a mensagem inteira no corpo, e você vê tudo chegando no terminal.

Para só olhar o que chega, um receptor de três linhas em Python resolve. Ele responde 200 na hora e imprime o corpo, que é exatamente o que você quer nessa fase:

receptor mínimo para inspecionar eventos
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        n = int(self.headers.get('Content-Length', 0))
        corpo = self.rfile.read(n)
        self.send_response(200); self.end_headers()   # responda primeiro
        print(self.headers.get('User-Agent'), corpo.decode('utf-8', 'replace'), flush=True)

HTTPServer(('0.0.0.0', 8080), H).serve_forever()
PY

Com o servidor no ar, abra o túnel para a porta 8080, copie a URL pública e cadastre-a na conexão, no painel. Marque só os eventos que interessam — começar por Message já cobre um fluxo de atendimento. Mande uma mensagem do seu celular para o número conectado e o evento aparece no terminal em segundos.

Qual é o formato do evento de mensagem recebida?

É o que o receptor acima imprime: o tipo do evento e a mensagem inteira. Uma resposta escrita chega assim:

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" }
    } }

Como simular um evento para testar o meu endpoint?

Salvando o payload num arquivo e mandando um POST para o seu próprio serviço. Isso separa dois problemas que costumam ser depurados juntos: se o seu endpoint trata o evento e se o túnel está funcionando. Teste o primeiro sem depender do segundo:

simulando a entrega do evento
# salve o payload acima como evento.json e dispare contra o seu endpoint
curl -sS -X POST http://localhost:8080/whatsapp/eventos \
  -H "Content-Type: application/json" \
  -H "User-Agent: zapon-webhook/1" \
  --data-binary @evento.json -w '\nHTTP %{http_code} em %{time_total}s\n'

# o corpo também pode chegar como formulário, com o evento dentro de jsonData
curl -sS -X POST http://localhost:8080/whatsapp/eventos \
  --data-urlencode "[email protected]" -w '\nHTTP %{http_code}\n'

Os dois comandos precisam devolver 200, e rápido. Se o segundo falhar, o seu código está lendo só JSON e vai perder eventos entregues como formulário. Repare no %{time_total}: a entrega tem janela curta, então um endpoint que demora segundos precisa passar a devolver 200 antes de processar, e não depois. As entregas reais chegam com o cabeçalho User-Agent: zapon-webhook/1, o que ajuda a separar tráfego legítimo 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 comando, 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 no terminal
200Requisição aceitaGuarde data.Id: jq -r '.data.Id' na saída
400Corpo inválidoValide o arquivo com jq . corpo.json e confira o formato do Phone: só dígitos, com país e DDD
401Token ausente ou inválidoTeste echo "$ZAPON_TOKEN" — variável vazia manda o header em branco e o erro é idêntico ao de token errado
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 script, a decisão fica clara quando você separa status e corpo:

enviar.sh — decidindo pelo código
#!/usr/bin/env bash
set -euo pipefail

enviar() {
  local corpo="$1" resposta status
  resposta=$(curl -sS -X POST https://api.zapon.dev/chat/send/text \
      -H "token: $ZAPON_TOKEN" -H "Content-Type: application/json" \
      --connect-timeout 10 --max-time 25 \
      --data-binary "$corpo" -w '\n%{http_code}')

  status=$(printf '%s' "$resposta" | tail -n1)
  corpo=$(printf  '%s' "$resposta" | sed '$d')

  case "$status" in
    200) printf '%s' "$corpo" | jq -r '.data.Id'; return 0 ;;
    400) echo "corpo inválido: $corpo"  >&2; return 1 ;;   # não repita
    401) echo "token inválido"          >&2; return 1 ;;   # não repita
    404) echo "rota inexistente"        >&2; return 1 ;;   # não repita
    *)   echo "temporário ($status)"    >&2; return 2 ;;   # vale tentar de novo
  esac
}

# repetição só do que é temporário: 1s, 2s, 4s
for tentativa in 1 2 3; do
  enviar '{"Phone":"5511999999999","Body":"Pedido 4821 confirmado."}' && break
  [ $? -eq 1 ] && break
  sleep $((2 ** (tentativa - 1)))
done

As flags --retry e --retry-all-errors do próprio curl fazem repetição automática, mas não distinguem 400 de 500: com elas você repete um corpo inválido três vezes sem ganho nenhum. Para envio de verdade, o case acima é mais honesto.

Ritmo de envio e preservação do número

Um laço de shell sobre um arquivo de telefones é a forma mais rápida de disparar em rajada sem perceber. Coloque intervalo, com variação, e trate cada linha como um envio que pode falhar sozinho:

um por vez, com intervalo
while IFS=';' read -r telefone nome; do
  jq -n --arg p "$telefone" --arg b "Olá, $nome! Sua entrega sai hoje." \
     '{Phone: $p, Body: $b}' \
  | curl -sS -X POST https://api.zapon.dev/chat/send/text \
      -H "token: $ZAPON_TOKEN" -H "Content-Type: application/json" \
      --data-binary @- -o /dev/null -w "$telefone HTTP %{http_code}\n" \
  | tee -a envios.log

  sleep "$(shuf -i 3-7 -n 1)"   # 3s a 7s, com variação
done < clientes.csv
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.

Um aviso sobre o laço acima: shell é ótimo para testar e ruim para campanha — não há controle de reentrada, o descadastro fica de fora e um Ctrl+C no meio deixa você sem saber onde parou. Depois de validar o formato no terminal, mova a rotina para uma linguagem com banco de dados: os tutoriais de Node.js e Python mostram o mesmo fluxo já com registro de envio e respeito ao opt-out.

Sobre custo, o cálculo é o mesmo em qualquer ferramenta: R$ 27 por mês por número conectado, sem cobrança por mensagem, dentro da cota de 300 mensagens por dia por número — cerca de 9.000 por mês, com HTTP 429 quando estoura. O teste é de 14 dias sem cartão e só começa a contar na primeira conexão — dá para explorar a API inteira no terminal antes de gastar o primeiro dia.

Perguntas frequentes

Os comandos funcionam no Windows?

Funcionam, com um ajuste de aspas. O curl.exe vem no Windows 10 e 11, mas o prompt de comando não entende aspas simples: guarde o corpo num arquivo e use --data-binary @corpo.json, que funciona igual nos três sistemas. No PowerShell há uma armadilha extra: curl é apelido de Invoke-WebRequest, então chame curl.exe pelo nome completo.

Preciso mesmo do jq?

Não para o primeiro teste, mas ele evita duas classes inteiras de erro: JSON malformado quando você monta o corpo à mão e falha de escape quando o texto tem aspas, acento ou emoji. Se não puder instalá-lo, use o arquivo com heredoc — é a alternativa mais segura no terminal puro.

Posso testar num cliente gráfico de API em vez do terminal?

Pode, e o mapeamento é direto: método POST, a URL do endpoint, um header token com o valor da conexão e o corpo em JSON cru. Um cuidado vale para qualquer ferramenta dessas: não sincronize na nuvem nem compartilhe com o time uma coleção com o token dentro — use variável de ambiente local.

Como envio para um grupo?

Use o identificador do grupo no lugar do telefone, no mesmo campo Phone dos endpoints de envio. A lista de grupos do número vem em GET /group/list, que no terminal é curl -sS -H "token: $ZAPON_TOKEN" https://api.zapon.dev/group/list | jq. São 18 endpoints de grupo no total, cobrindo criação, participantes, link de convite e configurações.

Dá para ver o schema de cada endpoint?

Dá: a referência interativa lista os 49 endpoints publicados com o schema de cada corpo e cada resposta, com os campos obrigatórios marcados. É o lugar certo para conferir antes de escrever um corpo novo à mão — e evita boa parte dos 400 que parecem inexplicáveis.

Um token serve para vários números?

Não: um token é um número. Se você opera vários, exporte uma variável por conexão (ZAPON_TOKEN_LOJA, ZAPON_TOKEN_SUPORTE) e troque no header conforme o número que deve enviar. Cada número conectado custa R$ 27 por mês, sem cobrança por mensagem, e tem cota própria de 300 mensagens por dia — cerca de 9.000 por mês — que protege aquele número contra bloqueio.

Preciso da API oficial da Meta ou de aprovação de template?

Não. O número conecta por QR Code ou código de pareamento, sem fila de aprovação e sem mensagem cadastrada antes. A contrapartida é que a conduta do número é de quem integra — motivo pelo qual esta página gasta uma seção inteira com ritmo de envio.

Leia também

Teste no terminal 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.