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

Como enviar mensagem de WhatsApp com Java

Do token ao fluxo de duas vias com o HttpClient do JDK 11: um cliente reutilizável, envio de texto, imagem e botões, verificação de número e um receptor de webhook que devolve 200 antes de processar — em Spring Boot e em Javalin.

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.

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ódigoSignificaO que fazer em Java
200Requisição aceitaGrave o Id junto do seu registro e siga
400Corpo inválidoLoga o JSON serializado e corrige: campo faltando, Phone fora do formato ou nome de campo trocado pelo mapeamento do Jackson
401Token ausente ou inválidoValide o token na inicialização; System.getenv devolvendo null vira NullPointerException no builder do header
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 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
    }
}
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.

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.