Quem integra WhatsApp em Java quase sempre está ligando um sistema que já existe: um ERP, um core bancário, um back-office de cobrança. A boa notícia é que desde o JDK 11 o java.net.http.HttpClient faz tudo o que essa integração precisa, sem Apache HttpClient, sem OkHttp e sem nenhum outro cliente HTTP na árvore de dependências. A única biblioteca inevitável é a de JSON, porque o JDK não traz uma.
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. Os exemplos usam Java 17 com Jackson, compilam sem framework e trazem o cuidado que a linguagem cobra — timeout explícito, exceções verificadas tratadas na fronteira e o HttpClient instanciado uma vez só.
O que você precisa antes da primeira linha
O que preciso para chamar a API do zapon em Java?
JDK 11 ou superior, uma biblioteca de JSON (Jackson ou Gson), 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 existe SDK do zapon no Maven Central e não faz falta: a API é REST com JSON, e o cliente cabe numa classe. Para o webhook você precisa de uma URL pública que aceite POST.
- JDK. O
HttpClient saiu do incubador no Java 11. Em Java 8 ele não existe e sobra o HttpURLConnection, que funciona mas dá muito mais trabalho — se der para atualizar, atualize. - JSON. Uma dependência, e só uma:
com.fasterxml.jackson.core:jackson-databind ou com.google.code.gson:gson. Os exemplos usam Jackson. - 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 Java?
Em variável de ambiente ou no cofre de segredos que a sua plataforma já usa, nunca em application.properties versionado. Em Spring Boot, @Value("${zapon.token}") lendo de ZAPON_TOKEN resolve sem código extra; fora do Spring, System.getenv com falha imediata na inicialização evita o pior dos cenários — o serviço subir com o header vazio e só descobrir no primeiro envio:
falhe cedo, não no primeiro envio
String token = System.getenv("ZAPON_TOKEN");
if (token == null || token.isBlank()) {
throw new IllegalStateException("ZAPON_TOKEN não definido");
}
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, então trate-o como credencial de banco de dados.
Primeiro envio de texto
Como enviar uma mensagem de WhatsApp com Java?
É um POST em https://api.zapon.dev/chat/send/text, com o header token, o Content-Type: application/json e um corpo de dois campos obrigatórios: Phone, só dígitos, com país e DDD, e Body, o texto da mensagem. Na resposta vem data.Id, o identificador da mensagem no WhatsApp — vale gravá-lo junto do registro que originou o envio.
O cliente abaixo é a peça central: um HttpClient criado uma vez, um ObjectMapper reaproveitado e um método chamar que traduz falha de rede e status HTTP para uma exceção não verificada. Todo o resto da página usa esse método:
Zapon.java — cliente com o HttpClient do JDK
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
public class ZaponException extends RuntimeException {
public final int status; // 0 = nem chegou na API (rede ou timeout)
public ZaponException(int status, String detalhe) {
super("zapon " + status + ": " + detalhe);
this.status = status;
}
}
public final class Zapon {
private static final String BASE = "https://api.zapon.dev";
// Um HttpClient por aplicação: ele é thread-safe e mantém o pool de conexões.
private final HttpClient http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ObjectMapper json = new ObjectMapper();
private final String token;
public Zapon(String token) { this.token = token; }
public JsonNode chamar(String rota, Object payload) {
String corpo;
try {
corpo = json.writeValueAsString(payload);
} catch (Exception e) {
throw new ZaponException(0, "payload não serializável: " + e.getMessage());
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + rota))
.header("token", token)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(25)) // timeout da resposta, não só da conexão
.POST(HttpRequest.BodyPublishers.ofString(corpo, StandardCharsets.UTF_8))
.build();
HttpResponse<String> res;
try {
res = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
} catch (java.io.IOException e) {
throw new ZaponException(0, e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restaure o sinal antes de sair
throw new ZaponException(0, "envio interrompido");
}
JsonNode raiz;
try {
raiz = json.readTree(res.body()); // erro de servidor pode não vir em JSON
} catch (Exception e) {
raiz = null;
}
boolean ok = res.statusCode() == 200
&& raiz != null && raiz.path("success").asBoolean(false);
if (!ok) {
String detalhe = raiz != null && raiz.hasNonNull("error")
? raiz.get("error").asText()
: res.body().substring(0, Math.min(200, res.body().length()));
throw new ZaponException(res.statusCode(), detalhe);
}
return raiz.path("data");
}
public String enviarTexto(String phone, String body) {
return chamar("/chat/send/text", Map.of("Phone", phone, "Body", body))
.path("Id").asText();
}
}
E a chamada, dentro do serviço que acabou de confirmar o pedido:
primeiro envio
Zapon zapon = new Zapon(System.getenv("ZAPON_TOKEN"));
try {
String id = zapon.enviarTexto(
"5511999999999",
"Pedido 4821 confirmado. Assim que o pacote sair, mandamos o rastreio por aqui.");
log.info("mensagem enviada: {}", id);
} catch (ZaponException e) {
log.error("falhou: status={} {}", e.status, e.getMessage());
}
O envelope de sucesso é sempre o mesmo: code, success e o bloco data com identificador e horário.
resposta da API
{
"code": 200,
"success": true,
"data": {
"Details": "Sent",
"Id": "90B2F8B13FAC8A9CF6B06E99C7834DC5",
"Timestamp": "2026-09-03T09:12:08-03:00"
}
}
Por que o timeout da requisição é obrigatório?
Porque connectTimeout e timeout resolvem problemas diferentes, e só o primeiro tem valor padrão útil. O connectTimeout limita o aperto de mão da conexão; o timeout do HttpRequest limita a espera pela resposta. Sem ele, uma chamada síncrona pode segurar a thread indefinidamente — e num pool de servlets isso não é uma requisição lenta, é o pool inteiro parando. Defina os dois, sempre.
Por que capturar InterruptedException em vez de engolir?
Porque http.send declara InterruptedException, e a saída preguiçosa é um catch (Exception ignored). Quando isso acontece dentro de um ExecutorService que está sendo desligado, o sinal de interrupção se perde e o worker continua rodando como se nada tivesse sido pedido — desligamento que trava, tarefa que não para. O Thread.currentThread().interrupt() do exemplo recoloca o sinal antes de lançar a exceção do zapon; é uma linha, e é a diferença entre um shutdown limpo e um processo zumbi.
Preciso de OkHttp, Apache HttpClient ou Feign?
Não para chamar a API. O HttpClient do JDK cobre o caso inteiro e evita uma dependência transitiva a mais no seu pom.xml. Se o projeto já padronizou um cliente — Spring com RestClient, Quarkus com REST Client, um Feign compartilhado —, use o mesmo: consistência dentro do serviço vale mais que a escolha em si. O que não vale a pena é introduzir um cliente HTTP novo só por causa desta integração.
Imagem e botões
Como enviar uma imagem pela API do zapon em Java?
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 Java, isso é Files.readAllBytes mais Base64.getEncoder(), ambos do JDK.
enviando uma imagem
import java.nio.file.*;
import java.util.Base64;
Path caminho = Path.of("etiquetas/4821.jpg");
String tipo = Files.probeContentType(caminho); // pode devolver null: tenha um padrão
if (tipo == null) tipo = "image/jpeg";
String imagem = "data:" + tipo + ";base64,"
+ Base64.getEncoder().encodeToString(Files.readAllBytes(caminho));
zapon.chamar("/chat/send/image", Map.of(
"Phone", "5511999999999",
"Image", imagem,
"Caption", "Etiqueta do pedido 4821. Cole na caixa antes de postar."));
Duas armadilhas específicas. A primeira é o Base64.getUrlEncoder(), que troca + e / por - e _ e produz um data URI que a API não reconhece — o codificador certo é o padrão, getEncoder(). A segunda é memória: base64 cresce o arquivo em cerca de um terço, e readAllBytes carrega tudo no heap. Um lote de fotos grandes processado em paralelo derruba um contêiner com heap apertado, então redimensione antes e trate um arquivo por vez.
Como mandar botões de resposta rápida?
São três ou quatro botões no POST /chat/send/buttons, cada um com um id seu e um text de até 20 caracteres — acima disso o WhatsApp corta o rótulo. O id volta inteiro no webhook, então é nele que vai 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
record Botao(String id, String text) {} // Jackson serializa record sem anotação
List<Botao> botoes = List.of(
new Botao("ped4821|hoje", "Pode entregar"), // 13 caracteres
new Botao("ped4821|amanha", "Prefiro amanhã"),
new Botao("ped4821|humano", "Falar com alguém"));
zapon.chamar("/chat/send/buttons", Map.of(
"Phone", "5511999999999",
"Body", "Pedido 4821 sai para entrega hoje. Tem alguém para receber à tarde?",
"Footer", "Loja Exemplo",
"Buttons", botoes));
O record aqui não é só elegância: os nomes dos componentes viram exatamente as chaves id e text do JSON, sem @JsonProperty. Se o seu projeto ainda usa classe com getters no padrão bean, confira o que sai — um getId() vira id, mas um campo Id com getter getId() também vira id, e a maiúscula que você esperava some. Serialize uma vez e imprima o JSON antes de brigar com um 400.
Sem o carimbo no id, a resposta chega só com o telefone e você adivinha de qual pedido ela fala. Com ele, o webhook faz split("\\|") e vai direto ao registro. Para mais de três opções, o caminho é o /chat/send/list, que abre um menu.
Verificar o número antes de enviar
Como saber se um número tem WhatsApp antes de disparar?
POST /user/check aceita vários números de uma vez no campo Phone e responde com IsInWhatsapp para cada um. Verificar antes do lote poupa chamada com telefone fixo e cadastro antigo, e evita a sequência de envios para números que não existem — justamente o padrão que aumenta o risco do seu número.
filtrando a lista antes do disparo
List<String> candidatos = List.of("5511999999999", "5511888888888", "551133334444");
JsonNode dados = zapon.chamar("/user/check", Map.of("Phone", candidatos));
List<String> validos = new ArrayList<>();
List<String> fora = new ArrayList<>();
for (JsonNode u : dados.path("Users")) {
(u.path("IsInWhatsapp").asBoolean(false) ? validos : fora)
.add(u.path("Query").asText());
}
log.info("com WhatsApp: {}", validos); // [5511999999999]
cadastroService.marcarSemWhatsapp(fora); // corrija a origem, não só o disparo de hoje
Grave o resultado no cadastro 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 Java 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 Java, o controller deve devolver 200 imediatamente e jogar o processamento para um executor ou uma fila.
O receptor precisa aceitar duas formas de corpo: JSON puro e formulário codificado, com o evento dentro do campo jsonData. Em Spring, o jeito mais seguro é receber o corpo como String e decidir você mesmo — um @RequestBody Map com consumes fixo devolve 415 quando o corpo chega como formulário, e o sintoma vira "o webhook não chega".
WebhookController.java — Spring Boot
@RestController
public class WebhookController {
private static final ObjectMapper JSON = new ObjectMapper();
private final ExecutorService pool = Executors.newFixedThreadPool(4);
private final Zapon zapon;
public WebhookController(Zapon zapon) { this.zapon = zapon; }
@PostMapping("/whatsapp/eventos")
public ResponseEntity<Void> receber(@RequestBody(required = false) String corpo,
@RequestParam Map<String, String> form) {
String cru = form.containsKey("jsonData") ? form.get("jsonData") : corpo;
pool.submit(() -> tratar(cru)); // processa fora do ciclo da requisição
return ResponseEntity.ok().build(); // 200 imediato
}
private void tratar(String cru) {
try {
JsonNode ev = JSON.readTree(cru);
if (!"Message".equals(ev.path("type").asText())) return;
JsonNode info = ev.path("event").path("Info");
if (info.path("IsFromMe").asBoolean(false)) return; // o que você enviou volta
if (info.path("IsGroup").asBoolean(false)) return;
if (!marcarComoVisto(info.path("ID").asText())) return; // reentrega acontece
String telefone = info.path("Chat").asText().split("@")[0];
JsonNode msg = ev.path("event").path("Message");
String botao = msg.path("buttonsResponseMessage")
.path("selectedButtonID").asText("");
String texto = msg.path("conversation").asText(
msg.path("extendedTextMessage").path("text").asText("")).trim();
if (!botao.isEmpty()) {
String[] partes = botao.split("\\|", 2);
pedidoService.registrarEscolha(partes[0], partes[1]);
zapon.enviarTexto(telefone, "hoje".equals(partes[1])
? "Combinado. A entrega sai hoje à tarde."
: "Certo, remarcamos para amanhã.");
return;
}
atendimentoService.enfileirar(telefone, texto); // o resto é assunto de gente
} catch (Exception e) {
log.error("falha ao tratar evento", e); // nunca deixe estourar no pool
}
}
}
Se você não quer o peso de um Spring só para receber eventos, um Javalin de vinte linhas faz o mesmo e sobe em menos de um segundo:
a mesma rota em Javalin
// io.javalin:javalin
Javalin app = Javalin.create().start(7070);
app.post("/whatsapp/eventos", ctx -> {
String cru = ctx.formParamMap().containsKey("jsonData")
? ctx.formParam("jsonData")
: ctx.body();
ctx.status(200); // responda antes de qualquer trabalho
CompletableFuture.runAsync(() -> tratar(cru));
});
Qual é o formato do evento de mensagem recebida?
O corpo já traz tudo: o tipo do evento e a mensagem inteira. Quem respondeu por escrito 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" }
} }
Repare que o bloco Message muda de forma conforme o tipo da mensagem. Mapeá-lo para uma classe rígida quebra no primeiro áudio recebido; por isso o exemplo navega com JsonNode e path(), que devolve nó ausente em vez de NullPointerException. Se preferir DTOs, marque a desserialização com FAIL_ON_UNKNOWN_PROPERTIES desligado e deixe todos os campos opcionais.
Por que responder 200 antes de processar?
Porque a entrega do evento tem janela curta, e um controller que consulta banco, chama outra API e só então retorna transforma cada evento numa espera. Em Java a tentação é confiar no pool do servidor de aplicação, mas ele é finito: uma rajada de eventos com processamento síncrono ocupa todas as threads e derruba o resto da aplicação junto. Responda 200, empurre para um executor próprio — ou, melhor ainda, grave numa fila e deixe o worker trabalhar. Vale registrar quem enviou o POST: as entregas chegam com o cabeçalho User-Agent: zapon-webhook/1.
Tratamento de erro por código HTTP
O que fazer em cada erro da API?
400 quer dizer corpo inválido: conserte o payload, não insista. 401 é token vazio ou de outra conexão. 404 é rota digitada errada. Só o 500 — junto com falha de rede — merece nova tentativa, e com espera crescente; repetir 400 ou 401 é queimar chamada à toa.
| Código | Significa | O que fazer em Java |
|---|
200 | Requisição aceita | Grave o Id junto do seu registro e siga |
400 | Corpo inválido | Loga o JSON serializado e corrige: campo faltando, Phone fora do formato ou nome de campo trocado pelo mapeamento do Jackson |
401 | Token ausente ou inválido | Valide o token na inicialização; System.getenv devolvendo null vira NullPointerException no builder do header |
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 um método que decide o que merece nova tentativa:
repetição seletiva com espera crescente
public JsonNode chamarComRetentativa(String rota, Object payload, int tentativas) {
for (int i = 1; ; i++) {
try {
return zapon.chamar(rota, payload);
} catch (ZaponException e) {
boolean temporario = e.status == 0 || e.status >= 500;
if (!temporario || i >= tentativas) throw e; // 400 e 401 não melhoram
try {
Thread.sleep(1000L * (1L << (i - 1))); // 1s, 2s, 4s
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw e;
}
}
}
}
Um detalhe que economiza plantão: deixe a falha definitiva virar registro visível — uma linha numa tabela de pendências, com o telefone e o motivo — em vez de um log.error 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 risco em Java mora no parallelStream() e no pool generoso: um lote de mil clientes vira mil mensagens em segundos, padrão que não se parece com nenhum uso humano. Serialize, e aceite que o lote demore:
um por vez, com intervalo
Random rnd = new Random();
for (Cliente cliente : pendentesDeHoje()) {
if (cliente.optOut()) continue; // quem pediu para sair, sai de todas as rotinas
try {
String id = zapon.enviarTexto(cliente.telefone(), textoPara(cliente));
envioRepo.registrar(cliente.id(), id); // guardar o Id evita mandar duas vezes
} catch (ZaponException e) {
envioRepo.registrarFalha(cliente.id(), e.getMessage());
}
try {
Thread.sleep(3000 + rnd.nextInt(4000)); // 3s a 7s, com variação
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break; // desligando: pare o lote
}
}
- 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
Thread.sleep(2000) exato mil vezes é tão artificial quanto o disparo em rajada. - Aqueça número novo. Comece com poucos envios por dia e aumente ao longo de semanas.
- Descadastro que funciona. Guarde o
optOut 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 Java é o mesmo de qualquer linguagem: 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 escrever a integração inteira, com testes, antes de gastar o primeiro dia.
Perguntas frequentes
Funciona em Java 8?
Funciona, com outro cliente HTTP: o HttpClient só existe do Java 11 em diante. Em Java 8 você usa HttpURLConnection, OkHttp ou o Apache HttpClient, mantendo os mesmos cuidados — timeout de conexão e de leitura, corpo lido como texto antes do parse e verificação do status junto com o campo success.
Jackson ou Gson?
Tanto faz para esta integração. O Jackson aparece nos exemplos porque já vem no Spring Boot; o Gson resolve igual e é mais leve. Se usar Gson, cuidado com o @SerializedName quando o nome do campo Java diferir do nome no JSON — os campos do zapon começam com maiúscula (Phone, Body), e é fácil enviar phone sem perceber e tomar 400.
Dá para usar o HttpClient de forma assíncrona?
Dá: troque send por sendAsync, que devolve CompletableFuture. É útil no receptor de webhook, quando você responde ao evento com um envio. Só lembre que assincronia serve para não bloquear thread enquanto se espera a rede, não para disparar tudo ao mesmo tempo: o intervalo entre envios continua necessário.
E as threads virtuais do Java 21?
Ajudam no receptor de webhook, onde cada evento vira uma tarefa curta que espera I/O — um Executors.newVirtualThreadPerTaskExecutor() substitui o pool fixo do exemplo sem mudar mais nada. No envio em lote elas não mudam a regra: continua sendo um por vez, com intervalo.
Como testo o webhook sem publicar o serviço?
Suba a aplicação local, exponha a porta com um túnel HTTP e cadastre a URL do túnel na conexão. Para testar só a sua regra, escreva um teste com MockMvc (ou um POST direto no endpoint) usando o payload de exemplo desta página — é o mesmo formato que chega em produção, e assim a lógica fica coberta sem depender de mensagem real.
Uma instância do cliente atende vários números?
Não: um token é um número. Crie uma instância de Zapon por conexão, com o token correspondente, e guarde-as num mapa se você atende vários clientes. O HttpClient interno pode até ser compartilhado entre elas, já que é thread-safe. Cada número conectado custa R$ 27 por mês, sem cobrança por mensagem.
Preciso da API oficial da Meta ou de aprovação de template?
Não. A conexão é feita lendo o QR Code com o WhatsApp do número, ou por código de pareamento, sem fila de aprovação e sem cadastrar mensagem antes. A contrapartida é que a conduta do número fica com quem integra, e é por isso que a seção de ritmo existe.