Quem sai da Z-API costuma sair pelo mesmo motivo: o preço por instância na escala deixa de fechar, ou o time quer webhooks com garantia de entrega. E quase sempre chega com a mesma dúvida: “vou ter que reescrever a integração inteira?”
Não. A WPPAPI usa deliberadamente o mesmo estilo de URL da Z-API — instância e token no path — e os mesmos nomes de campo no envio de texto. Na prática, trocar a base URL já resolve a maior parte do envio.
Mas existem três pontos que mudam de verdade e que quebram em silêncio se você não olhar antes. É sobre eles que este post é.
O que é literalmente igual
Na Z-API você envia assim:
POST https://api.z-api.io/instances/{id}/token/{token}/send-text
{ "phone": "5511999999999", "message": "Olá" }
Na WPPAPI:
POST https://api.wpp-api.com/instances/{id}/token/{token}/send-text
{ "phone": "5511999999999", "message": "Olá" }
Mesmo path, mesmo corpo, mesmo formato de telefone (só dígitos, com DDI). Se seu código monta a URL a partir de uma variável de ambiente, o primeiro envio funciona trocando três variáveis. O token também pode ir no header Client-Token ou Instance-Token, se você prefere manter a URL limpa.
Ponto 1: a resposta do envio tem outro formato
Este é o que mais pega gente desprevenida, porque não dá erro HTTP — só quebra na hora de gravar o ID da mensagem.
A Z-API devolve os identificadores na raiz (zaapId, messageId, id). A WPPAPI devolve um envelope:
{ "success": true, "result": { ... } }
Se você faz const id = res.data.messageId, isso vira undefined silenciosamente e seu log de auditoria fica vazio. Ajuste todo ponto que lê a resposta de envio antes de virar a chave — é uma busca por zaapId e messageId no projeto.
Ponto 2: mídia tem um endpoint por tipo
A Z-API separa por rota (send-image, send-document/{extension}, send-audio…). A WPPAPI também separa, mas com nomes e campos próprios:
| Tipo | WPPAPI | Corpo |
|---|---|---|
| Texto | POST .../send-text |
{ phone, message } |
| Imagem | POST .../send-image |
{ phone, image, caption } |
| Documento | POST .../send-file |
{ phone, document, fileName } |
| Vídeo | POST .../send-video |
{ phone, video, caption } |
| Áudio / PTT | POST .../send-voice |
{ phone, audio } |
| Localização | POST .../send-location |
{ phone, latitude, longitude, title } |
| Contato | POST .../send-contact |
{ phone, contacts } |
| Validar número | GET .../check-number?phone=... |
— |
| QR Code | GET .../qr-code |
— |
| Status da conexão | GET .../status |
— |
Nos campos de mídia você passa uma URL direta (image: "https://...") ou base64. Se o valor não começa com http, ele é tratado como base64 — o prefixo data:image/png;base64, é removido automaticamente, então os dois formatos funcionam. Para controlar o mimetype e o nome do arquivo, use o objeto file:
{
"phone": "5511999999999",
"file": {
"data": "<base64>",
"mimetype": "application/pdf",
"filename": "boleto-agosto.pdf"
}
}
Limite de 100 MB por arquivo, em todos os planos. Além do básico existem enquete (send-poll), lista (send-list), botões (send-buttons), figurinha (send-sticker), reação (PUT .../reaction), encaminhamento (forward) e download de mídia recebida (GET .../media/{messageId}). A referência completa está nos docs.
Ponto 3: o webhook é outro payload (e assinado)
Aqui não tem atalho: o formato é diferente. A Z-API entrega os campos na raiz, com o texto aninhado em text.message. A WPPAPI entrega um envelope com o evento e os dados em data:
{
"event": "received",
"wahaEvent": "message",
"instanceId": "inst_abc123",
"timestamp": "2026-08-03T14:20:11.145Z",
"data": {
"id": "...",
"from": "[email protected]",
"chatId": "[email protected]",
"body": "Olá",
"type": "chat",
"fromMe": false,
"notifyName": "Maria"
}
}
Se seu handler já tem regra de negócio dentro dele, não reescreva: traduza na entrada. Um adaptador resolve, e ele também valida a assinatura HMAC — que a WPPAPI envia no header X-WPPAPI-Signature (HMAC-SHA256 em hex do corpo cru):
import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/webhook", (req, res) => {
const esperado = crypto
.createHmac("sha256", process.env.WPP_WEBHOOK_SECRET)
.update(req.body) // Buffer cru, antes do JSON.parse
.digest("hex");
const recebido = req.get("X-WPPAPI-Signature") || "";
if (
recebido.length !== esperado.length ||
!crypto.timingSafeEqual(Buffer.from(recebido), Buffer.from(esperado))
) {
return res.sendStatus(401);
}
const evt = JSON.parse(req.body.toString());
if (evt.event !== "received") return res.sendStatus(200);
// Traduz para o formato que seu código já entende
meuHandlerAntigo({
phone: evt.data.from.split("@")[0],
senderName: evt.data.notifyName,
isGroup: evt.data.from.endsWith("@g.us"),
fromMe: evt.data.fromMe,
messageId: evt.data.id,
text: { message: evt.data.body },
});
res.sendStatus(200);
});
Duas observações que fazem diferença na prática. Primeiro: valide a assinatura antes de qualquer JSON.parse — se você usa express.json(), o corpo cru já foi consumido e o HMAC nunca vai bater. Segundo: responda 200 rápido e processe em fila. A WPPAPI faz retry automático e manda o que falhou de vez para uma dead-letter queue, o que é bom — mas só é bom se seu endpoint responder rápido e for idempotente por data.id. Se você processa síncrono e demora, vai receber a mesma mensagem de novo. O passo a passo completo está no guia de webhooks em Node.js.
Rollout sem parar a operação
Não vire a chave de uma vez. O caminho que menos dói:
- Crie uma instância na WPPAPI e conecte um número secundário. Nada em produção ainda.
- Aponte o webhook para um endpoint paralelo e rode os dois formatos em paralelo por alguns dias, comparando o que cada um entregou.
- Migre por instância, não por percentual de tráfego. Cada número tem uma sessão própria; migrar metade das mensagens de um mesmo número entre dois provedores só confunde o histórico.
- Deixe o número antigo conectado na Z-API até o fim do ciclo pago. Não há custo em manter os dois enquanto você valida.
Um número não pode estar conectado em dois provedores ao mesmo tempo — o WhatsApp derruba a sessão anterior. É por isso que o teste começa com um número secundário.
O que não muda: o risco
Vale a honestidade: WPPAPI e Z-API são as duas APIs não oficiais, conectando via QR Code sem a Cloud API da Meta. Trocar de provedor não reduz risco de banimento em nada. Se seu número foi banido disparando promoção para lista fria, ele vai ser banido de novo — valem as mesmas regras de aquecimento, ritmo e opt-in que detalhamos em como evitar banimento.
O que muda é preço previsível na escala (R$ 70 no Básico, R$ 180 no Pro com 3 instâncias, R$ 45 por adicional), webhooks com HMAC, retry e DLQ, e integração nativa com Chatwoot. Se isso resolve sua dor, o teste custa uma tarde.
Crie uma instância e teste por 3 dias, sem cartão — dá tempo de rodar o adaptador acima com um número secundário antes de decidir.