Webhooks e eventos — wpp-engine v1
Como o engine avisa o seu sistema sobre mensagens recebidas (texto e mídia), confirmação de envio e mudança de sessão.
Data: 2026-07-18 (atualizado com upgrade Hermes)
Fontes: código dowpp-engine, quickstart do console, ADR-006, OpenAPI (PATCH /v1/tenants/me).
Visão geral
- Você registra uma URL HTTPS com
PATCH /v1/tenants/me. - O engine faz
POSTJSON nessa URL a cada evento. - Você valida a assinatura HMAC no body bruto, responde
200rápido e processa o evento (idealmente em fila). - Use o
iddo evento para não processar duas vezes (retries possíveis).
flowchart LR
Engine[wpp-engine] -->|POST JSON + HMAC| YourURL[Seu webhook HTTPS]
YourURL -->|200 OK| Engine
YourURL --> Queue[Fila / handler]
Queue --> CRM[Seu produto]
Configurar o endpoint
curl -sS -X PATCH "https://wpp-api.oneclient.tech/v1/tenants/me" \
-H "Authorization: Bearer $WPP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://seu-servidor.example/webhooks/wpp",
"webhook_secret": "um-segredo-forte-min-8-chars"
}'
| Campo | Regra |
|---|---|
webhook_url | Obrigatório; URI válida |
webhook_secret | Opcional; 8–255 chars. Se omitir, o engine gera — guarde na hora (a API devolve só webhook_secret_prefix) |
Limite do v1: 1 URL por tenant. Não há API de múltiplos endpoints nem filtro “só estes eventos”. Replay de item na DLQ: POST /admin/webhooks/:id/retry (operacional; sem listagem pública).
Requisitos da URL de destino
O engine valida o destino antes de cada entrega. Três regras derrubam a entrega na hora, sem retry — são erro de configuração, e repetir por 6 horas só atrasaria o diagnóstico:
| Regra | O que acontece | dlq.reason |
|---|---|---|
| Redirect não é seguido. Sua URL precisa ser a final. | Qualquer 3xx (301, 302, 307, 308…) conta como falha de entrega | redirect_not_supported |
| O host precisa resolver em DNS público. | Falha na resolução | webhook_dns_lookup_failed |
O IP resolvido não pode ser privado (10.x, 192.168.x, 127.x, 169.254.x, IPv6 local). | Entrega bloqueada | webhook_url_resolves_to_private_ip |
Atenção se sua URL passa por redirect. O caso comum é cadastrar
https://seudominio.com/webhooks/wppquando o servidor responde301parahttps://www.seudominio.com/...(ou o contrário). Antes isso funcionava porque o engine seguia o redirect; hoje falha. Cadastre o endereço final — o que responde200direto.Como conferir em 5 segundos:
curl -sS -o /dev/null -w '%{http_code} -> %{redirect_url}\n' -X POST https://sua-url/webhooks/wpp. Se o código for3xx, use o endereço que aparece emredirect_url.
O motivo é segurança: a validação de destino não serve para nada se a própria URL cadastrada puder devolver 302 apontando para a rede interna do engine (SSRF). O item vai direto para a DLQ com last_status_code e location preenchidos, para você ver qual redirect estava no caminho.
Headers de cada entrega
| Header | Conteúdo |
|---|---|
X-WPP-Engine-Signature | HMAC-SHA256 em hex do raw body (bytes exatos recebidos) |
X-WPP-Engine-Event-Id | Mesmo valor de payload.id (ex.: evt_…) |
X-WPP-Engine-Timestamp | ISO8601; mesmo valor de payload.timestamp |
Content-Type | application/json (esperado) |
Envelope do payload
Todo evento segue este formato:
{
"id": "evt_550e8400-e29b-41d4-a716-446655440000",
"type": "message.received",
"timestamp": "2026-07-18T12:10:00.000Z",
"tenant_id": "344ca521-2b0e-4dcc-9bd8-dced610cf63a",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "whatsapp",
"data": { }
}
| Campo | Descrição |
|---|---|
id | ID único do evento — use para dedupe |
type | Nome do evento (tabela abaixo) |
timestamp | Quando o engine emitiu o evento |
tenant_id | Seu tenant |
session_id | Sessão WhatsApp relacionada |
channel | Sempre whatsapp no v1 |
data | Payload específico do tipo |
Catálogo de eventos (v1)
type | Quando acontece | Uso típico |
|---|---|---|
message.received | Mensagem inbound 1:1 persistida (grupos usam group.message.received) | Atualizar inbox / bot / CRM / Whisper |
message.sent | Outbound aceito pelo pipeline | Confirmar que saiu da API |
message.delivered | Entregue no aparelho do destino | “1 check” / entregue |
message.read | Lida (checks azuis) | “2 checks azuis” |
message.failed | Falha de entrega outbound | Retry / alerta |
session.connected | QR escaneado / sessão live | Liberar envios |
session.disconnected | Conexão perdida | Pausar campanhas / alertar |
session.banned | Número restringido pelo WhatsApp | Trocar número (suporte OneClient) |
session.qr_generated | QR criado/renovado | UI de pairing |
group.message.received | Mensagem em grupo habilitado, conforme groups.notify | Bot que responde quando marcado |
group.message.sent | Envio a grupo aceito (to_group) | Confirmar injeção externa |
group.digest.ready | Resumo periódico do grupo gerado pelo LLM | Documentação / estratégia |
message.received (texto ou mídia)
{
"id": "evt_550e8400-e29b-41d4-a716-446655440000",
"type": "message.received",
"timestamp": "2026-07-18T12:10:00.000Z",
"tenant_id": "344ca521-2b0e-4dcc-9bd8-dced610cf63a",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "whatsapp",
"data": {
"message_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"external_message_id": "wamid.xxx",
"from": "+5511987654321",
"to": "+5511999999999",
"text": "primeira mensagem",
"content_type": "text",
"media_id": null,
"mime_type": null,
"size_bytes": null,
"signed_url": null
}
}
Para áudio/imagem/vídeo/documento inbound, content_type é o tipo, media_id aponta para GET /v1/media/{id}, e signed_url vale ~24h (renove via GET media).
STT / Whisper: use sempre data.media_id (não-nulo em mídia bem persistida). Voice notes chegam tipicamente como OGG/Opus (.oga). Se media_id vier vazio, o asset não foi gravado — não tente baixar URL interna do WAHA.
Outros data (confirmados no código do engine)
| type | data |
|---|---|
message.sent / .delivered / .read | { message_id, external_message_id, status, reason } |
message.failed | { message_id, external_message_id, status, reason, reason_raw } — reason é enum estável: recipient_not_on_whatsapp | session_disconnected | media_error | unknown |
session.connected | { status: "connected", connected_at } |
session.disconnected | { status: "disconnected", reason } |
session.banned | { status: "banned", reason } |
| group.message.received | { session_id, group_id, external_message_id, participant_phone, participant_name, content_type, content_text, mentioned_me, quoted_from_me } |
| group.message.sent | { session_id, group_id, message_id, external_message_id, status } |
| group.digest.ready | { session_id, group_id, period_start, period_end, summary_markdown, model, stats } |
Grupos: mensagens @g.us nunca geram message.received — quando a sessão
tem grupos habilitados e o grupo está na allowlist, o evento é
group.message.received, respeitando groups.notify (none não notifica,
mentions só com menção ou reply, all notifica tudo). Mensagem enviada pelo
próprio número nunca notifica (evita loop). @broadcast continua sempre
descartado.
Verificação HMAC (obrigatória)
Regra de ouro: verificar a assinatura nos bytes brutos do body antes de JSON.parse.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {Buffer|string} rawBody - body bruto (Buffer preferível)
* @param {string} signatureHex - header X-WPP-Engine-Signature
* @param {string} secret - webhook_secret
*/
export function verifyWppEngineSignature(rawBody, signatureHex, secret) {
if (!signatureHex || !secret) return false;
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
try {
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(String(signatureHex), "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
} catch {
return false;
}
}
// Exemplo Express — precisa do raw body
// app.post('/webhooks/wpp', express.raw({ type: '*/*' }), (req, res) => {
// const ok = verifyWppEngineSignature(
// req.body,
// req.header('X-WPP-Engine-Signature'),
// process.env.WPP_WEBHOOK_SECRET
// );
// if (!ok) return res.status(401).send('invalid signature');
// const event = JSON.parse(req.body.toString('utf8'));
// // dedupe por event.id ...
// res.status(200).json({ received: true });
// });
Python
import hashlib
import hmac
def verify_wpp_engine_signature(raw_body: bytes, signature_hex: str, secret: str) -> bool:
if not signature_hex or not secret:
return False
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_hex)
# Exemplo FastAPI:
# @app.post("/webhooks/wpp")
# async def webhook(request: Request):
# raw = await request.body()
# sig = request.headers.get("X-WPP-Engine-Signature", "")
# if not verify_wpp_engine_signature(raw, sig, WEBHOOK_SECRET):
# raise HTTPException(401, "invalid signature")
# event = json.loads(raw)
# return {"received": True}
Boas práticas de produção
- Responda 200 rápido (ideal < 2–5 s). Trabalho pesado → fila interna.
- Timeout do engine: 10s (
WEBHOOK_HTTP_TIMEOUT_MS). - Até 5 tentativas com backoff 60s → 5m → 15m → 1h → 6h.
- Após esgotar: evento vai para DLQ interna (
webhook_events.dlq). Replay operacional:POST /admin/webhooks/:id/retry(Bearer do tenant). - Exceção:
3xx, DNS quebrado e IP privado não entram no ciclo de retry (ver “Requisitos da URL de destino”).
- Timeout do engine: 10s (
- Deduplicação: persista
event.id(ou headerX-WPP-Engine-Event-Id) e ignore reentregas. - Não confie no body sem HMAC — qualquer um poderia POSTAR na sua URL.
- HTTPS apenas em produção.
- Idempotência no seu lado: se
message.deliveredchegar duas vezes, o estado do CRM deve permanecer correto. - Trate
session.disconnected: pause envios e peça novo QR (GET /v1/sessions/{id}/qrou crie nova sessão). - Mídia inbound: use
data.media_id+GET /v1/media/{id}(Whisper / download). Osigned_urldo webhook expira em ~24h. - Não use o Console BFF como receptor — o webhook aponta para o seu servidor.
Confirmação de envio (mapa mental)
POST /messages → status "sent" na resposta HTTP
↓
webhook message.sent
↓
webhook message.delivered ← chegou no celular
↓
webhook message.read ← abriu / leu
(ou)
webhook message.failed
Use o message_id / data do webhook para correlacionar com o id retornado no POST .../messages.
O que ainda não existe no v1 (webhooks)
| Recurso | Status |
|---|---|
| Múltiplos endpoints | Não |
| Filtro “só estes eventos” via API | Não |
| Listagem pública de DLQ | Não (UI do console é mock) |
| Replay de 1 evento na DLQ | Sim — POST /admin/webhooks/:id/retry |
| Assinatura alternativa (mTLS, JWT) | Não — só HMAC-SHA256 |
| Eventos de grupo | Sim — group.message.received, group.message.sent, group.digest.ready (opt-in por sessão + allowlist); @broadcast filtrado |