Atualizado em 20/10/2026
O webhook da API do WhatsApp é uma URL HTTPS do seu sistema que recebe, via POST, cada evento da conta: mensagens recebidas, mudanças de status (sent, delivered, read, failed) e alertas da conta. Um endpoint bem feito valida a origem da requisição, responde 200 em poucos segundos, coloca o evento numa fila e processa em segundo plano, ignorando duplicados pelo ID. Na integração direta com a Meta há também um passo de verificação por GET; na Vectalk, a URL é registrada por API e recebe o mesmo payload da Meta.
Este guia mostra o formato dos eventos, as diferenças entre integrar direto e pela Vectalk, e um servidor completo em Node.js e em Python.
Como o fluxo de eventos funciona
Na integração direta, a Meta chama o endpoint configurado no seu app. Com a Vectalk, a Meta chama a Vectalk, que repassa o evento para todas as URLs ativas registradas para aquela instância:
Direto: Meta ──POST──▶ seu endpoint
Vectalk: Meta ──POST──▶ Vectalk ──POST──▶ suas URLs (uma ou várias)
O corpo é o mesmo nos dois casos. O que muda é como a URL é cadastrada e como a requisição é autenticada.
| Aspecto | Cloud API direta | Vectalk |
|---|---|---|
| Cadastro da URL | Painel de Apps > WhatsApp > Configuração | POST /api/webhooks com instance_id, url e name |
| Verificação do endpoint | GET com hub.mode, hub.verify_token e hub.challenge | Não descrita na documentação da Vectalk |
| Assinatura | Header X-Hub-Signature-256 (HMAC-SHA256 com o App Secret) | Não descrita na documentação da Vectalk |
| URLs por conta | Um endpoint por app (com substituições por conta ou número) | Várias URLs por instância, todas recebem o mesmo evento |
| Tempo de resposta | A Meta reenvia se não receber 200 | Responder 200 em menos de 20 segundos; senão a Vectalk reenvia |
| Novas tentativas | Frequência decrescente por até 7 dias | Reenvio documentado, sem prazo informado |
Fontes: webhooks da Cloud API (atualizada em 26/06/2026), primeiros passos com webhooks da Graph API e webhooks na documentação da Vectalk.
Registrando o webhook na Vectalk
curl -X POST https://apis.vectalk.com.br/api/webhooks \
-H "Authorization: Bearer SEU_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"instance_id": "SEU_INSTANCE_ID",
"url": "https://meuapp.com.br/whatsapp/eventos",
"name": "Produção"
}'
A resposta traz o id do webhook e active: true. Com PATCH no mesmo recurso você troca a URL, o nome ou desativa o webhook; os endpoints estão em Registrar webhook e Atualizar webhook. A URL precisa ser HTTPS.
Ter várias URLs por instância ajuda em dois casos: separar produção e homologação e mandar o mesmo evento para dois sistemas (o CRM e o data warehouse, por exemplo) sem montar um repasse próprio.
Verificação do endpoint (integração direta)
Quando você configura o webhook no painel da Meta, ela faz um GET na sua URL com três parâmetros:
GET https://meuapp.com.br/whatsapp/eventos?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=meu-token-secreto
O endpoint deve conferir se hub.verify_token é igual ao valor que você definiu no painel e devolver o hub.challenge no corpo, com status 200. Qualquer outra resposta faz a verificação falhar.
Os payloads que você vai receber
Todo evento chega com object: "whatsapp_business_account" e uma lista entry. Cada entry tem uma lista changes, e cada change tem field e value. Para mensagens e status, field é messages.
Mensagem recebida
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "558430000000",
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [{ "profile": { "name": "João Silva" }, "wa_id": "5584988888888" }],
"messages": [{
"from": "5584988888888",
"id": "wamid.HBgLNTUx...",
"timestamp": "1760000000",
"type": "text",
"text": { "body": "Olá, quero mais informações" }
}]
}
}]
}]
}
O campo type diz onde está o conteúdo. Os mais comuns:
type | Onde ler |
|---|---|
text | text.body |
image, video, audio, document, sticker | <tipo>.id (baixe pelo endpoint de mídia), caption, filename |
interactive | interactive.button_reply ou interactive.list_reply |
location | location.latitude, location.longitude |
reaction | reaction.message_id, reaction.emoji |
order | order.catalog_id, order.product_items[] |
Status de mensagem enviada
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "558430000000", "phone_number_id": "PHONE_NUMBER_ID" },
"statuses": [{
"id": "wamid.HBgLNTUx...",
"status": "delivered",
"timestamp": "1760000010",
"recipient_id": "5584988888888",
"pricing": { "billable": true, "pricing_model": "PMP", "type": "regular", "category": "utility" }
}]
}
}]
}]
}
O id do status é o wamid devolvido quando você enviou a mensagem. Quando status é failed, o objeto traz errors[] com code, title e message. O objeto pricing indica categoria e se a mensagem é cobrável; a referência completa está na documentação do webhook de status. Para entender o que cada categoria custa, veja Quanto custa a API do WhatsApp no Brasil em 2026.
Um mesmo POST pode trazer várias mensagens ou vários status. Itere as listas em vez de ler só o primeiro item.
Boas práticas
Responda 200 rápido e processe depois
O endpoint não deve chamar o seu banco, a IA ou outra API antes de responder. Grave o corpo bruto numa fila (Redis, SQS, RabbitMQ, uma tabela de eventos) e devolva 200. Um worker separado faz o trabalho pesado. A Vectalk documenta um limite de 20 segundos para a resposta; fique bem abaixo disso.
Seja idempotente
A Meta avisa que as novas tentativas podem duplicar notificações, e um proxy também pode reenviar. Use o id da mensagem (ou a combinação id + status, no caso de status) como chave única. Se a chave já foi processada, descarte.
Valide a assinatura
Na integração direta, a Meta assina cada POST com HMAC-SHA256 do corpo, usando o App Secret do seu app, e envia o resultado no header X-Hub-Signature-256 no formato sha256=<hash>. Calcule o hash sobre o corpo bruto, antes de qualquer parse de JSON, e compare em tempo constante. Com a Vectalk, siga o mecanismo de autenticação que a Vectalk indicar para as requisições repassadas; enquanto isso não estiver definido no seu ambiente, use uma URL com caminho difícil de adivinhar, restrinja o endpoint a HTTPS e não confie no conteúdo para ações sensíveis sem checar o phone_number_id esperado.
Não dependa da ordem
delivered pode chegar antes de sent, e read pode chegar sem delivered. Guarde o status mais avançado e o timestamp de cada um, sem regredir.
Planeje para payloads grandes
A Meta informa que os payloads de webhook podem ter até 3 MB. Ajuste o limite de corpo do seu servidor (o padrão do Express é 100 kb).
Monitore
Registre quantos eventos chegam por minuto e quantos falham no worker. Uma queda brusca de eventos de mensagens é, muitas vezes, o primeiro sinal de webhook desativado, certificado vencido ou número desconectado.
Exemplo completo em Node.js (Express)
import express from 'express'
import crypto from 'node:crypto'
import { createClient } from 'redis'
const app = express()
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN // integração direta
const APP_SECRET = process.env.META_APP_SECRET // integração direta
// Guarda o corpo bruto para validar a assinatura
app.use('/whatsapp/eventos', express.raw({ type: 'application/json', limit: '3mb' }))
// Verificação do endpoint (só na integração direta com a Meta)
app.get('/whatsapp/eventos', (req, res) => {
const { 'hub.mode': mode, 'hub.verify_token': token, 'hub.challenge': challenge } = req.query
if (mode === 'subscribe' && token === VERIFY_TOKEN) return res.status(200).send(challenge)
return res.sendStatus(403)
})
function assinaturaValida(req) {
const header = req.get('X-Hub-Signature-256') || ''
const esperado = 'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(req.body).digest('hex')
const a = Buffer.from(header)
const b = Buffer.from(esperado)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post('/whatsapp/eventos', async (req, res) => {
if (APP_SECRET && !assinaturaValida(req)) return res.sendStatus(401)
await redis.lPush('whatsapp:eventos', req.body.toString('utf8'))
res.sendStatus(200)
})
app.listen(3000)
E o worker, que consome a fila e descarta duplicados:
async function processar(evento) {
for (const entry of evento.entry ?? []) {
for (const change of entry.changes ?? []) {
const v = change.value ?? {}
for (const msg of v.messages ?? []) {
const novo = await redis.set(`wa:msg:${msg.id}`, '1', { NX: true, EX: 60 * 60 * 24 * 8 })
if (!novo) continue // duplicado
await tratarMensagem(v.metadata.phone_number_id, msg) // sua regra de negócio
}
for (const st of v.statuses ?? []) {
const novo = await redis.set(`wa:st:${st.id}:${st.status}`, '1', { NX: true, EX: 60 * 60 * 24 * 8 })
if (!novo) continue
await atualizarStatus(st) // grava status, erros e pricing
}
}
}
}
while (true) {
const item = await redis.brPop('whatsapp:eventos', 0)
try {
await processar(JSON.parse(item.element))
} catch (err) {
console.error('falha ao processar evento', err)
await redis.lPush('whatsapp:eventos:erro', item.element)
}
}
A expiração de oito dias nas chaves cobre a janela de novas tentativas da Meta, que vai até sete dias.
Exemplo completo em Python (FastAPI)
import hashlib, hmac, json, os
import redis.asyncio as redis
from fastapi import FastAPI, Request, Response
app = FastAPI()
r = redis.from_url(os.environ["REDIS_URL"])
VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN") # integração direta
APP_SECRET = os.environ.get("META_APP_SECRET", "") # integração direta
@app.get("/whatsapp/eventos")
async def verificar(request: Request):
q = request.query_params
if q.get("hub.mode") == "subscribe" and q.get("hub.verify_token") == VERIFY_TOKEN:
return Response(content=q.get("hub.challenge", ""), media_type="text/plain")
return Response(status_code=403)
def assinatura_valida(corpo: bytes, header: str) -> bool:
esperado = "sha256=" + hmac.new(APP_SECRET.encode(), corpo, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, header or "")
@app.post("/whatsapp/eventos")
async def receber(request: Request):
corpo = await request.body()
if APP_SECRET and not assinatura_valida(corpo, request.headers.get("X-Hub-Signature-256")):
return Response(status_code=401)
await r.lpush("whatsapp:eventos", corpo)
return Response(status_code=200)
Worker em Python:
import asyncio, json, os
import redis.asyncio as redis
r = redis.from_url(os.environ["REDIS_URL"])
OITO_DIAS = 60 * 60 * 24 * 8
async def processar(evento: dict):
for entry in evento.get("entry", []):
for change in entry.get("changes", []):
v = change.get("value", {})
for msg in v.get("messages", []):
if not await r.set(f"wa:msg:{msg['id']}", 1, nx=True, ex=OITO_DIAS):
continue
await tratar_mensagem(v["metadata"]["phone_number_id"], msg)
for st in v.get("statuses", []):
if not await r.set(f"wa:st:{st['id']}:{st['status']}", 1, nx=True, ex=OITO_DIAS):
continue
await atualizar_status(st)
async def main():
while True:
_, item = await r.brpop("whatsapp:eventos")
try:
await processar(json.loads(item))
except Exception as exc:
print("falha ao processar evento", exc)
await r.lpush("whatsapp:eventos:erro", item)
asyncio.run(main())
As funções tratar_mensagem e atualizar_status (e as equivalentes em Node) são a sua regra de negócio: responder, gravar no CRM, acionar um agente.
O que fazer com cada tipo de evento no worker
Separar o tratamento por tipo de evento deixa o worker mais fácil de manter. Uma divisão que funciona bem:
- Mensagens recebidas: grave a mensagem, atualize o horário da última mensagem do contato (é ele que diz se a janela de 24 horas está aberta) e só então acione o chatbot, o agente ou a fila de atendimento humano.
- **Status
sent,deliverederead**: atualize o registro da mensagem enviada pelowamid, guardando o horário de cada etapa. Esses dados alimentam relatórios de entrega e de leitura. - **Status
failed**: grave o código de erro e decida a ação. Alguns erros pedem nova tentativa mais tarde; outros indicam que o envio não vai funcionar sem mudança, como um template fora da janela ou um número inválido. Não reenvie em laço. - Outros campos (
account_update, alertas da conta, mudanças de template): direcione para um canal de alerta do time técnico, porque costumam exigir ação humana.
Mantenha também uma fila de erros, como nos exemplos acima. Um evento que falhou no processamento não deve sumir: ele precisa ficar disponível para reprocessamento depois que o bug for corrigido.
Testando localmente
Para receber eventos reais no computador, exponha a porta local com um túnel HTTPS (ngrok, Cloudflare Tunnel ou similar) e registre a URL do túnel como um webhook adicional da instância, com um nome como “dev”. Como a Vectalk entrega o evento para todas as URLs ativas, o ambiente de desenvolvimento recebe o mesmo tráfego da produção sem interferir nela. Desative o webhook de dev quando terminar.
Também dá para testar o parser sem tráfego real, salvando payloads de exemplo e enviando com curl:
curl -X POST http://localhost:3000/whatsapp/eventos \
-H "Content-Type: application/json" \
--data @exemplos/mensagem-texto.json
Erros comuns
| Sintoma | Causa provável |
|---|---|
| Mensagens processadas duas vezes | Falta de deduplicação por id |
| Eventos chegando atrasados em rajada | Endpoint lento; a origem acumulou novas tentativas |
| Assinatura nunca bate | Hash calculado sobre o JSON já convertido, não sobre o corpo bruto |
413 Payload Too Large | Limite de corpo do servidor abaixo de 3 MB |
| Só a primeira mensagem do lote é tratada | Código lendo messages[0] em vez de iterar |
Status regredindo de read para delivered | Eventos fora de ordem sobrescrevendo o estado |
Perguntas frequentes
Preciso responder alguma coisa no corpo do webhook?
Não. Basta o status HTTP 200. O conteúdo da resposta é ignorado, exceto na verificação por GET da integração direta, em que o corpo deve ser o hub.challenge.
Posso usar a mesma URL para vários números?
Sim. Use metadata.phone_number_id para saber a qual número o evento pertence. Na Vectalk, cada webhook é registrado por instância, e você pode apontar várias instâncias para a mesma URL.
Por quanto tempo a Meta tenta reenviar um evento?
Por até sete dias, com frequência decrescente, segundo a documentação de webhooks da Cloud API. Na Vectalk, a documentação informa que o evento é reenviado quando o endpoint não responde 200 em até 20 segundos.
Como recebo as mensagens que o dono responde pelo app, na coexistência?
Pelo campo de webhook smb_message_echoes. O artigo Coexistência no WhatsApp explica os campos adicionais.
Estou migrando de Baileys. O formato é muito diferente?
Sim. Os eventos seguem a estrutura entry[].changes[].value da Meta, os contatos usam wa_id e a mídia vem por id. O guia Como migrar para a API oficial do WhatsApp cobre essa transição. Para começar na Vectalk, crie sua conta em apps.vectalk.com.br/register; dúvidas de conta ficam na Central de Ajuda.
Fontes consultadas em 20/10/2026: Webhooks da Cloud API, Webhooks da Graph API: primeiros passos, Referência do webhook de status, Webhooks na documentação da Vectalk.
