# 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 do `wpp-engine`, quickstart do console, ADR-006, OpenAPI (`PATCH /v1/tenants/me`).

---

## Visão geral

1. Você registra **uma** URL HTTPS com `PATCH /v1/tenants/me`.
2. O engine faz `POST` JSON nessa URL a cada evento.
3. Você **valida a assinatura HMAC** no body bruto, responde `200` rápido e processa o evento (idealmente em fila).
4. Use o `id` do evento para **não processar duas vezes** (retries possíveis).

```mermaid
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

```bash
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/wpp` quando o servidor responde `301` para `https://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 responde `200` direto.
>
> 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 for `3xx`, use o endereço que aparece em `redirect_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:

```json
{
  "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)

```json
{
  "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

```javascript
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

```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

1. **Responda 200 rápido** (ideal &lt; 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”).
2. **Deduplicação:** persista `event.id` (ou header `X-WPP-Engine-Event-Id`) e ignore reentregas.
3. **Não confie no body sem HMAC** — qualquer um poderia POSTAR na sua URL.
4. **HTTPS apenas** em produção.
5. **Idempotência no seu lado:** se `message.delivered` chegar duas vezes, o estado do CRM deve permanecer correto.
6. **Trate `session.disconnected`:** pause envios e peça novo QR (`GET /v1/sessions/{id}/qr` ou crie nova sessão).
7. **Mídia inbound:** use `data.media_id` + `GET /v1/media/{id}` (Whisper / download). O `signed_url` do webhook expira em ~24h.
8. **Não use o Console BFF** como receptor — o webhook aponta para **o seu** servidor.

---

## Confirmação de envio (mapa mental)

```text
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 |

---

## Links

- [Guia de integração](./2026-07-18-guia-de-integracao.md)
- [Referência API](./2026-07-18-referencia-api-v1.md)
- [Capacidades e limites](./2026-07-18-capacidades-e-limites.md)
