# wpp-engine > API REST gerenciada (WhatsApp não oficial) da OneClient. > Base URL do engine: https://wpp-api.oneclient.tech > Auth: Authorization: Bearer wae_live_… > Modo atual: signup público com aprovação + cobrança manual (preço por instância sob consulta). ## Docs para humanos - Hub: https://wpp-admin.oneclient.tech/docs - Quickstart: https://wpp-admin.oneclient.tech/docs/quickstart - Criar conta: https://wpp-admin.oneclient.tech/signup - Guia de integração: https://wpp-admin.oneclient.tech/docs/integracao - Referência da API v1: https://wpp-admin.oneclient.tech/docs/referencia - Webhooks e eventos: https://wpp-admin.oneclient.tech/docs/webhooks - Capacidades e limites: https://wpp-admin.oneclient.tech/docs/limites ## Docs para agentes (markdown puro) - Índice completo concatenado: https://wpp-admin.oneclient.tech/llms-full.txt - Guia de integração: https://wpp-admin.oneclient.tech/docs/integracao.md - Referência da API v1: https://wpp-admin.oneclient.tech/docs/referencia.md - Webhooks e eventos: https://wpp-admin.oneclient.tech/docs/webhooks.md - Capacidades e limites: https://wpp-admin.oneclient.tech/docs/limites.md ## Contrato máquina (engine) - OpenAPI: https://wpp-api.oneclient.tech/v1/openapi.json - Swagger UI: https://wpp-api.oneclient.tech/v1/docs - Health: https://wpp-api.oneclient.tech/v1/health ## Conceitos - Conta: solicite em /signup → aprovação → email+senha → revele a API key one-shot no console. - Tenant = cliente (1 API key; rotação via suporte OneClient). - Instância/sessão = 1 número WhatsApp (QR). Status: pending|qr|pairing|connected|disconnected|banned|revoked|logged_out. - Webhook: 1 URL por tenant, HMAC-SHA256 (X-WPP-Engine-Signature). - message.received inclui mídia: content_type, media_id, mime_type, size_bytes, signed_url (~24h; renovar via GET /v1/media/{id}). - Envio: text, image, audio (PTT/voice note), video, document (via POST /v1/media/upload ou /v1/media/upload-url). - Reconnect: POST /v1/sessions/{id}/reconnect (sem QR se auth válida; senão devolve QR). - Webhook conferência: GET /v1/tenants/me; PATCH preserva webhook_secret se omitido (watchdog anti-drift). - Grupos: opt-in por sessão (groups.enabled, default false). Desligado, inbound @g.us continua ignorado; @broadcast é sempre ignorado. - Grupos · notify: none (silencioso, só envio via to_group) | mentions (webhook só com menção/reply) | all. Eventos: group.message.received, group.message.sent, group.digest.ready. - Grupos · resumo diário: groups.digest {enabled, time HH:MM, timezone IANA} gera resumo por LLM e entrega em group.digest.ready. - Grupos · envio: POST /v1/sessions/{id}/messages com to_group (JID @g.us), só para grupo na allowlist, com teto diário próprio (default 20/grupo). - Rate limit: 429 + Retry-After (default 60 req/s por tenant). - message.failed.reason: recipient_not_on_whatsapp | session_disconnected | media_error | unknown (+ reason_raw). - Não suportado no v1: criar/administrar grupos (criar grupo, mexer em admins), botões, templates HSM Meta, rotação self-service de key, cobrança automática. --- # Documentação completa --- # Guia de integração # Guia de integração — wpp-engine v1 > **Objetivo:** do zero à primeira mensagem enviada e ao primeiro webhook recebido (texto **e** mídia), no estilo dos quickstarts de Evolution API / Z-API. > **Base URL:** `https://wpp-api.oneclient.tech` > **Data:** 2026-07-18 (atualizado com upgrade Hermes) --- ## Pré-requisitos 1. Conta no **Console** wpp-engine: 1. Solicite em [`/signup`](https://wpp-engine-console.vercel.app/signup) (empresa, contato, email, telefone). 2. Após aprovação, você recebe **email + senha temporária** (e o link do console). 3. Faça login e revele a API key **uma única vez** em **API Keys**. 2. Número de WhatsApp que será conectado (celular ou chip dedicado). 3. Um endpoint HTTPS público na sua aplicação para receber webhooks (ngrok/localtunnel serve para teste). 4. Ferramenta HTTP: `curl`, Postman ou código (Node.js abaixo). --- ## Passo 1 — Obter a API key 1. Faça login no Console com as credenciais recebidas na aprovação. 2. Abra **API Keys**. 3. Clique para revelar a chave (`wae_live_…`). 4. **Copie e guarde agora** — ela só é mostrada uma vez. Não há recuperação pelo painel. Se a key for perdida ou comprometida, contate o **suporte OneClient** para rotação. A nova key volta a aparecer **uma vez** no console. Teste a autenticação: ```bash export WPP_API_KEY="wae_live_REPLACE_ME" export WPP_BASE="https://wpp-api.oneclient.tech" curl -sS "$WPP_BASE/v1/sessions" \ -H "Authorization: Bearer $WPP_API_KEY" ``` Resposta esperada (lista vazia ou com sessões): ```json { "data": [], "pagination": { "next_cursor": null }, "error": null } ``` Se vier `401` / `INVALID_API_KEY`, a chave está errada ou ainda não foi provisionada. Se vier `429` / `RATE_LIMITED`, você estourou o limite (default **60 req/s** por tenant) — respeite o header `Retry-After`. --- ## Passo 2 — Criar uma sessão (conectar o número) Uma **sessão** = um número WhatsApp = uma **instância**. ```bash curl -sS -X POST "$WPP_BASE/v1/sessions" \ -H "Authorization: Bearer $WPP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+5511987654321", "display_name": "atendimento-clinica-x" }' ``` > Aceitos: `phone` **ou** `phone_number` (E.164 com `+` — o país do `+` é respeitado; não force `55` em cima de `+1…`). Opcionais: `display_name`, `external_id` (seu ID interno). Exemplo de resposta `200`: ```json { "data": { "session_id": "550e8400-e29b-41d4-a716-446655440000", "status": "qr", "qr_code": "data:image/png;base64,iVBORw0KGgo...", "qr_expires_at": "2026-07-18T12:05:00.000Z" }, "error": null } ``` **O que fazer com o QR:** 1. Guarde o `session_id`. 2. Abra o `qr_code` (é um data URL PNG) no navegador ou no Console (onboarding / tela de sessões). 3. No celular: WhatsApp → Aparelhos conectados → Conectar um aparelho → escaneie. Se o QR expirar, peça um novo: ```bash SESSION_ID="550e8400-e29b-41d4-a716-446655440000" curl -sS "$WPP_BASE/v1/sessions/$SESSION_ID/qr" \ -H "Authorization: Bearer $WPP_API_KEY" ``` Confirme o status: ```bash curl -sS "$WPP_BASE/v1/sessions/$SESSION_ID" \ -H "Authorization: Bearer $WPP_API_KEY" ``` Valores possíveis de `status`: `pending` | `qr` | `pairing` | `connected` | `disconnected` | `banned` | `revoked` | `logged_out`. Quando conectado, `status` = `connected`. Em produção, prefira o webhook `session.connected` em vez de só fazer polling. Se a sessão cair depois (`session.disconnected`): ```bash curl -sS -X POST "$WPP_BASE/v1/sessions/$SESSION_ID/reconnect" \ -H "Authorization: Bearer $WPP_API_KEY" ``` - Se voltar `status: "connected"` → pronto (auth ainda válida). - Se voltar `status: "qr"` + `qr_code` → humano precisa escanear de novo. Quota default: até **50** sessões ativas por tenant (`QUOTA_EXCEEDED_SESSIONS` se passar). --- ## Passo 3 — Enviar a primeira mensagem (texto) A sessão precisa estar **conectada**. Destino em E.164 **com `+`** (ex.: `+5511987654321` ou `+15617650750`). Números com `+` não recebem `55` automático. ```bash curl -sS -X POST "$WPP_BASE/v1/sessions/$SESSION_ID/messages" \ -H "Authorization: Bearer $WPP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+5511987654321", "content": { "type": "text", "text": "Olá! Mensagem de teste do wpp-engine." }, "idempotency_key": "teste-primeira-msg-001" }' ``` Resposta típica: ```json { "data": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "status": "sent", "external_message_id": "true_abc123", "sent_at": "2026-07-18T12:10:00.000Z" }, "error": null } ``` - `id` — ID interno da mensagem (use para correlacionar webhooks e `GET /v1/messages/{id}`). - Confirmações de entrega/leitura chegam por **webhook** (`message.delivered`, `message.read`). - `idempotency_key` (8–120 chars): a mesma chave **sempre** devolve a mesma mensagem (índice permanente; sem TTL). Ideal para retries. Se a sessão ainda estiver em QR: `409` / `SESSION_NOT_CONNECTED`. --- ## Passo 4 — Enviar imagem, áudio, vídeo ou documento Fluxo em **duas etapas**: 1. Upload → recebe `media_id` 2. Envio referenciando esse `media_id` ### 4.1 Upload ```bash # multipart/form-data — campo do arquivo: `file` curl -sS -X POST "$WPP_BASE/v1/media/upload" \ -H "Authorization: Bearer $WPP_API_KEY" \ -F "file=@./foto.jpg" ``` Resposta esperada: ```json { "data": { "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "mime_type": "image/jpeg", "size_bytes": 184320, "signed_url": "https://...", "expires_at": "2026-07-18T13:00:00.000Z" }, "error": null } ``` | Erro | Quando | |------|--------| | `413` / `MEDIA_TOO_LARGE` | Acima do limite do tipo (imagem 5 MB, áudio/vídeo 16 MB, documento 100 MB; hard cap 100 MB) | | `400` / `INVALID_MEDIA_MIME` | MIME não aceito | | `403` / `QUOTA_EXCEEDED_STORAGE` | Storage do tenant esgotado (default 10 GB) | `signed_url` do upload vale ~**24h**; `GET /v1/media/{id}` regenera. ### 4.1b Alternativa — URL pública (carrossel / CDN) ```bash curl -sS -X POST "$WPP_BASE/v1/media/upload-url" \ -H "Authorization: Bearer $WPP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://cdn.exemplo.com/foto.jpg"}' ``` Mesma resposta do multipart (`id` = `media_id`). Só **HTTPS público**; localhost/IPs privados são bloqueados (anti-SSRF). Erro `502` / `MEDIA_FETCH_FAILED` se o download falhar. ### 4.2 Enviar a mídia ```bash MEDIA_ID="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" curl -sS -X POST "$WPP_BASE/v1/sessions/$SESSION_ID/messages" \ -H "Authorization: Bearer $WPP_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"to\": \"+5511987654321\", \"content\": { \"type\": \"image\", \"media_id\": \"$MEDIA_ID\", \"caption\": \"Resultado do exame\" } }" ``` | type | Campos | Comportamento | |------|--------|----------------| | `text` | `text` (1–4096) | Texto | | `image` | `media_id` + caption opcional ≤ 1024 | Imagem | | `audio` | `media_id` | **Voice note (PTT / bolinha)** via WAHA `sendVoice`. Se o upload não for OGG/Opus (ex.: MP3 do TTS), o engine **converte** para OGG/Opus antes do envio (necessário para download no app mobile). | | `video` | `media_id` + caption opcional | Vídeo | | `document` | `media_id` + caption opcional | Arquivo anexado | MIME aceitos no upload: jpeg/png/webp/gif/heic; audio/mpeg|ogg|opus|mp4|aac|webm|wav|amr|…; video/mp4|webm|…; pdf/doc/docx/xlsx. TTS em MP3 é aceito; a conversão para PTT é automática. Inbound: voice notes WhatsApp (`.oga` / `audio/ogg; codecs=opus`) geram `media_id` no webhook — use `GET /v1/media/{id}` para STT. --- ## Passo 5 — Configurar o webhook Registre **uma** URL HTTPS por tenant: ```bash curl -sS -X PATCH "$WPP_BASE/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" }' ``` - Se omitir `webhook_secret`, o engine gera um — **guarde na hora** (a API devolve só um prefixo). - Detalhes: [Webhooks e eventos](./2026-07-18-webhooks-e-eventos.md). Eventos do fluxo normal: | Evento | Quando | |--------|--------| | `session.connected` | QR escaneado / sessão live | | `session.qr_generated` | QR criado/renovado | | `message.sent` | Envio aceito pelo pipeline | | `message.delivered` | Chegou no aparelho | | `message.read` | Lida (checks azuis) | | `message.failed` | Falha (`reason` enum + `reason_raw`) | | `message.received` | Alguém respondeu (texto **ou** mídia 1:1) | | `session.disconnected` | Conexão caiu | | `session.banned` | Número restringido | **Grupos:** mensagens de `@g.us` nunca geram `message.received`. Se a sessão foi criada com `groups.enabled: true` e o grupo está na allowlist, o evento é `group.message.received` (filtrado por `groups.notify`), mais `group.message.sent` e `group.digest.ready`. `@broadcast` é sempre descartado. Detalhes na seção 9 da [Referência API](./2026-07-18-referencia-api-v1.md). --- ## Passo 6 — Receber mídia inbound (ex.: áudio → Whisper) Quando o cliente envia um áudio/imagem, o webhook traz: ```json { "type": "message.received", "data": { "message_id": "...", "external_message_id": "...", "from": "+5511...", "to": "+5511...", "text": null, "content_type": "audio", "media_id": "uuid-do-asset", "mime_type": "audio/ogg", "size_bytes": 12345, "signed_url": "https://... (válido ~24h)" } } ``` **Fluxo recomendado (produção):** 1. Validar HMAC no raw body. 2. Responder `200` imediato. 3. Se `data.media_id` estiver presente: - Preferir `GET /v1/media/{media_id}` para obter `signed_url` fresco (não depender só do URL do webhook após horas). - Baixar os bytes e enviar ao Whisper / seu pipeline. 4. Dedupe por `event.id` / `X-WPP-Engine-Event-Id`. ```bash curl -sS "$WPP_BASE/v1/media/$MEDIA_ID" \ -H "Authorization: Bearer $WPP_API_KEY" ``` Alternativa: `GET /v1/messages/{message_id}` também devolve `media_asset_id` e metadados. --- ## Passo 7 — Exemplo completo em Node.js ```javascript const BASE = "https://wpp-api.oneclient.tech"; const API_KEY = process.env.WPP_API_KEY; async function api(method, path, body) { const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, }); const json = await res.json().catch(() => null); if (res.status === 429) { const retry = res.headers.get("Retry-After") ?? "1"; throw new Error(`RATE_LIMITED retry_after=${retry}`); } if (!res.ok) { throw new Error(`${res.status} ${JSON.stringify(json?.error ?? json)}`); } return json; } async function main() { const created = await api("POST", "/v1/sessions", { phone: "+5511987654321", display_name: "demo", }); const sessionId = created.data.session_id; console.log("session_id:", sessionId, "status:", created.data.status); console.log("Escaneie o QR antes de continuar."); for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 5000)); const detail = await api("GET", `/v1/sessions/${sessionId}`); console.log("poll status:", detail.data.status); if (detail.data.status === "connected") break; } const sent = await api("POST", `/v1/sessions/${sessionId}/messages`, { to: "+5511987654321", content: { type: "text", text: "hello from wpp-engine" }, idempotency_key: `demo-${Date.now()}`, }); console.log("message id:", sent.data.id, "status:", sent.data.status); await api("PATCH", "/v1/tenants/me", { webhook_url: "https://seu-servidor.example/webhooks/wpp", webhook_secret: "um-segredo-forte-min-8-chars", }); console.log("webhook configurado"); } main().catch(console.error); ``` --- ## Boas práticas (checklist de implementação) ### Segurança - [ ] API key só no servidor (nunca no front / app do paciente). - [ ] Webhook HTTPS + verificação HMAC com `timingSafeEqual` no **raw body**. - [ ] Deduplicação por `event.id` / `X-WPP-Engine-Event-Id`. ### Confiabilidade - [ ] Responder `200` rápido no webhook; processar Whisper/CRM em fila. - [ ] Usar `idempotency_key` em todo envio que possa ser retried. - [ ] Tratar `429` com backoff baseado em `Retry-After`. - [ ] Tratar `session.disconnected` / `session.banned` (pause envios; tente `POST .../reconnect` antes de alertar humano). - [ ] Watchdog de webhook: `GET /v1/tenants/me` periódico; `PATCH` **só** em drift e **sem** `webhook_secret`. - [ ] Para `message.failed`, use `reason` (`recipient_not_on_whatsapp` | `session_disconnected` | `media_error` | `unknown`) e logue `reason_raw`. ### Mídia e voz - [ ] Outbound TTS: upload MP3 (ou outro áudio) → `type: "audio"` → bolinha PTT; engine converte para OGG/Opus se necessário (mobile baixa corretamente). - [ ] Carrossel/imagem por URL: `POST /v1/media/upload-url` → use o `media_id` no envio. - [ ] Inbound: use `media_id` + `GET /v1/media/{id}` (STT); não confie só no `signed_url` antigo do webhook. - [ ] Telefones sempre E.164 com `+` completo (`+55…` BR, `+1…` EUA, etc.) — sem forçar `55` em cima de outro país. ### Produto / comercial - [ ] Grupos só chegam se a sessão foi habilitada (`groups`) e o grupo está na allowlist — e no evento `group.*`, não em `message.received`. - [ ] Não prometa botões / templates Meta oficiais. - [ ] Preço por instância: sob consulta; cobrança manual nesta fase. - [ ] Ler [Capacidades e limites](./2026-07-18-capacidades-e-limites.md). ### Retry do engine (webhooks) - Timeout: 10s. - Até 5 tentativas: backoff 60s → 5m → 15m → 1h → 6h. - Após esgotar: DLQ interna; replay operacional `POST /admin/webhooks/:id/retry`. --- ## Próximos documentos - [Referência completa da API](./2026-07-18-referencia-api-v1.md) - [Webhooks e eventos](./2026-07-18-webhooks-e-eventos.md) - [Capacidades e limites](./2026-07-18-capacidades-e-limites.md) - [Índice](./README.md) --- # Referência da API v1 # Referência da API v1 — wpp-engine > **Fonte primária:** código do `wpp-engine` + OpenAPI `https://wpp-api.oneclient.tech/v1/openapi.json` (versão `0.1.0`) > **Servers:** `https://wpp-api.oneclient.tech` · `http://localhost:3000` (dev) > **Auth scheme:** HTTP Bearer (`bearerAuth` — API Key `wae_live_*`) > **Data:** 2026-07-18 (atualizado com upgrade Hermes) Todas as respostas de sucesso usam o envelope: ```json { "data": { ... }, "error": null } ``` Listagens paginadas incluem: ```json { "data": [ ... ], "pagination": { "next_cursor": null }, "error": null } ``` Erros: ```json { "data": null, "error": { "code": "CODIGO", "message": "descrição humana" } } ``` --- ## Convenções gerais ### Headers | Header | Obrigatório | Valor | |--------|-------------|--------| | `Authorization` | Sim (exceto health) | `Bearer wae_live_…` | | `Content-Type` | Em POST/PATCH JSON | `application/json` | ### Telefone (E.164) Use formato internacional com `+`, mínimo 8 caracteres nos campos validados pelo schema. Ex.: `+5511987654321`, `+15617650750`. **Regra importante:** se o número já vier com `+`, o engine **respeita o E.164 completo** e **não** prefixa `55` automaticamente. Errado: mandar `+15617650750` e esperar que vire Brasil. Certo: `+15617650750` (EUA) ou `+5562999400110` (Brasil). Sem `+`, o normalizer pode assumir contexto BR — prefira sempre E.164 com `+`. Inbound (JIDs do WhatsApp) também é normalizado para E.164 com `+` (inclui restauração do 9º dígito BR quando aplicável). ### Paginação Query params: | Recurso | Params | Defaults | |---------|--------|----------| | `GET /v1/sessions` | `cursor` (ISO timestamp da página anterior), `limit` | limit 20, max 100 | | `GET /v1/sessions/{id}/messages` | `cursor` (base64), `limit`, `direction` | limit 50, max 100 | A resposta traz `pagination.next_cursor`. Se `null`, acabou a página. ### Idempotência (envio) Campo opcional `idempotency_key` (string 8–120) em `POST .../messages`. A mesma chave **sempre** devolve a mesma mensagem (índice permanente; sem TTL). Use em retries da sua aplicação. ### Rate limit São três limites, aplicados nesta ordem. Todos respondem `429` com header `Retry-After` (segundos) e `error.code = RATE_LIMITED`. | Limite | Chave | Default | Quando você encosta nele | |--------|-------|---------|--------------------------| | Por tenant | API key | **60 req/s** | É o limite comercial — o único que uma integração normal enxerga | | Por IP | IP de origem | **6.000 req/min** | Só em volume muito acima do uso normal (proteção da plataforma) | | Falhas de autenticação | IP de origem | **20 por 10 min** | Chave errada em loop; acerte a `Authorization` e o contador zera na janela | Variáveis: `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS`, `RATE_LIMIT_IP_MAX` / `RATE_LIMIT_IP_WINDOW_MS`, `RATE_LIMIT_AUTH_FAILURE_MAX` / `RATE_LIMIT_AUTH_FAILURE_WINDOW_MS`. O limite de falhas de autenticação é novo: antes, requisição com chave inválida não era contada em lugar nenhum, e dava para varrer chaves sem custo. Se você estiver rotacionando a key, faça o deploy do novo valor de uma vez em vez de deixar processos antigos repetindo a chave velha — o período de graça (abaixo) existe justamente para isso. Desliga com `RATE_LIMIT_ENABLED=false` (só em emergência). ### Rotação de API key `POST /v1/tenants/me/rotate-key` gera uma chave nova e mantém a atual válida por um **período de graça de 24h** — dá para trocar a chave nos seus serviços sem janela de indisponibilidade. ```bash curl -sS -X POST "https://wpp-api.oneclient.tech/v1/tenants/me/rotate-key" \ -H "Authorization: Bearer $WPP_API_KEY" ``` ```json { "data": { "api_key": "wae_live_…", "api_key_prefix": "wae_live_a1b2c3…", "previous_key_valid_until": "2026-09-04T18:00:00.000Z" }, "error": null } ``` A chave nova aparece **uma única vez** nessa resposta — o engine guarda só o hash. Passado `previous_key_valid_until`, a chave antiga é recusada com `401 INVALID_API_KEY`, mesmo que ainda esteja em algum `.env` esquecido. Rotacionar de novo dentro da graça encerra a graça da penúltima: só existem duas chaves válidas por vez, nunca três. Roteiro sem downtime: rotacione → publique a chave nova nos seus serviços → confira os logs → não precisa fazer mais nada, a antiga expira sozinha. ### Códigos HTTP recorrentes | Status | Significado típico | |--------|--------------------| | 200 | Sucesso | | 204 | Sucesso sem corpo (DELETE sessão) | | 400 | Payload inválido | | 401 | API key ausente/inválida | | 403 | Sem permissão / quota | | 404 | Recurso não encontrado | | 409 | Conflito de estado (ex.: sessão não conectada) | | 413 | Payload/arquivo grande demais (upload) | | 429 | Rate limit (`RATE_LIMITED`) | | 503 | Serviço indisponível / dependência | Códigos de erro frequentes: | code | HTTP | Quando | |------|------|--------| | `INVALID_API_KEY` | 401 | Bearer inválido | | `INVALID_PHONE` | 400 | Telefone inválido na criação de sessão | | `SESSION_NOT_FOUND` | 404 | `session_id` desconhecido neste tenant | | `QUOTA_EXCEEDED_SESSIONS` | 403 | Quota de sessões do tenant (default 50) | | `SESSION_NOT_CONNECTED` | 409 | Envio com sessão em `qr` / desconectada | | `MEDIA_TOO_LARGE` | 413 | Upload acima do limite do tipo | | `INVALID_MEDIA_MIME` | 400 | MIME não aceito | | `QUOTA_EXCEEDED_STORAGE` | 403 | Storage do tenant esgotado | | `RATE_LIMITED` | 429 | Estourou um dos três limites (tenant, IP ou falhas de auth) | | `ROTATION_UNAVAILABLE` | 503 | Rotação de key indisponível no momento — a key atual continua valendo | --- ## 1. Health ### `GET /v1/health` Sem autenticação. Checagem de vida do engine. **Exemplo de resposta** (observado em produção 2026-07-18): ```json { "status": "ok", "version": "0.1.0", "uptime_seconds": 57280 } ``` > Este path **não** aparece no OpenAPI publicado; é endpoint operacional. Use para monitoramento. --- ## 2. Sessões ### `POST /v1/sessions` — Criar sessão Cria sessão e devolve QR para parear o WhatsApp. **Body (JSON)** | Campo | Tipo | Obrigatório | Notas | |-------|------|-------------|--------| | `phone` | string ≥ 8 | um de `phone` / `phone_number` | E.164 | | `phone_number` | string ≥ 8 | um de `phone` / `phone_number` | Alias aceito | | `display_name` | string 1–120 | Não | Nome amigável | | `external_id` | string 1–120 | Não | Seu ID interno | | `groups` | objeto | Não | Política de grupos (ver seção 9). Omitido = tudo desligado | **Resposta 200** ```json { "data": { "session_id": "uuid", "status": "qr", "qr_code": "data:image/png;base64,...", "qr_expires_at": "2026-07-18T12:05:00.000Z", "groups": { "enabled": false, "notify": "none", "digest": { "enabled": false, "time": "20:00", "timezone": "America/Sao_Paulo" } } }, "error": null } ``` `qr_code` e `qr_expires_at` podem ser `null` em estados posteriores. **Erros documentados no OpenAPI:** 400, 401, 403, 503. --- ### `GET /v1/sessions` — Listar sessões **Query:** `cursor` (opcional, timestamp ISO da última página), `limit` (opcional, 1–100, default 20). **Resposta 200** ```json { "data": [ { } ], "pagination": { "next_cursor": null }, "error": null } ``` Itens do array: schema aberto (`additionalProperties`) no OpenAPI — trate como objetos de sessão; o detalhe tipado está em `GET /v1/sessions/{id}`. **Erros:** 401. --- ### `GET /v1/sessions/{id}` — Detalhar sessão **Path:** `id` (UUID da sessão). **Resposta 200** ```json { "data": { "id": "uuid", "external_id": "meu-id-ou-null", "phone_normalized": "+5511987654321", "display_name": "atendimento", "status": "string", "tier": "string", "connected_at": "ISO8601|null", "last_connected_at": "ISO8601|null", "last_disconnected_at": "ISO8601|null" }, "error": null } ``` Valores de `status` (CHECK no schema): `pending` | `qr` | `pairing` | `connected` | `disconnected` | `banned` | `revoked` | `logged_out`. Valores de `tier`: `cold` | `warm` | `hot` | `verified` (criação inicia em `cold`). **Erros:** 401, 404. --- ### `PATCH /v1/sessions/{id}` — Atualizar sessão Edita `display_name` e a política de grupos depois da criação. É um PATCH real: campo ausente mantém o valor atual, inclusive dentro de `groups.digest`. **Body (JSON)** | Campo | Tipo | Notas | |-------|------|-------| | `display_name` | string 1–120 | Opcional | | `groups` | objeto | Opcional (ver seção 9) | **Resposta 200:** `{ "data": { "session_id", "display_name", "groups" }, "error": null }` **Erros:** 400, 401, 404. --- ### `DELETE /v1/sessions/{id}` — Revogar sessão Desconecta / remove a sessão. **Resposta:** `204` sem corpo (ou corpo vazio). O console BFF trata resposta vazia como sucesso. **Erros:** 401, 404. --- ### `GET /v1/sessions/{id}/qr` — Obter / renovar QR Use quando o QR anterior expirou e a sessão ainda não conectou. **Resposta 200** ```json { "data": { "qr_code": "data:image/png;base64,...", "qr_expires_at": "ISO8601" }, "error": null } ``` **Erros:** 401, 404, 503. --- ### `POST /v1/sessions/{id}/reconnect` — Reconectar sessão Tenta reconectar sem QR (WAHA reaproveita a auth salva). Se o pareamento caiu, devolve QR novo — sem precisar deletar/recriar a sessão. **Aceita:** `disconnected`, `logged_out`, `qr`, `pending`, `pairing`. **Rejeita:** `connected` → `409 SESSION_ALREADY_CONNECTED`; `revoked` / `banned` → `409 SESSION_NOT_RECONNECTABLE`. **Resposta 200** (mesmo shape do create): ```json { "data": { "session_id": "uuid", "status": "connected", "qr_code": null, "qr_expires_at": null }, "error": null } ``` Se precisar de QR: `status: "qr"` + `qr_code` data URL. Eventos: `session.connected` ou `session.qr_generated`. **Erros:** 401, 404, 409, 503. --- ## 3. Mensagens ### `POST /v1/sessions/{id}/messages` — Enviar mensagem **Body** | Campo | Tipo | Obrigatório | |-------|------|-------------| | `to` | string ≥ 8 | Sim, se não usar `to_group` (E.164, chat 1:1) | | `to_group` | string terminando em `@g.us` | Sim, se não usar `to` (mutuamente exclusivo com `to`) | | `content` | objeto discriminado por `type` | Sim | | `idempotency_key` | string 8–120 | Não | #### `content` — texto ```json { "type": "text", "text": "olá" } ``` - `text`: 1–4096 caracteres. #### `content` — imagem ```json { "type": "image", "media_id": "uuid", "caption": "opcional ≤ 1024" } ``` #### `content` — áudio ```json { "type": "audio", "media_id": "uuid" } ``` Entrega como **voice note (PTT)** no WhatsApp (WAHA `sendVoice`), não como arquivo anexado. Se o `mime_type` do asset não for `audio/ogg` / `audio/opus`, o engine chama WAHA `POST /api/{session}/media/convert/voice` e envia o OGG/Opus resultante. Upload em MP3 (TTS) é suportado; sem essa conversão o app mobile costuma falhar ao baixar a bolinha (Web pode funcionar). #### `content` — vídeo ```json { "type": "video", "media_id": "uuid", "caption": "opcional ≤ 1024" } ``` #### `content` — documento ```json { "type": "document", "media_id": "uuid", "caption": "opcional ≤ 1024" } ``` **Resposta 200** ```json { "data": { "id": "uuid", "status": "sent", "external_message_id": "string", "sent_at": "ISO8601|null" }, "error": null } ``` **Erros OpenAPI:** 400, 401, 403, 404, 409, 503. Confirmações posteriores (`delivered` / `read` / `failed`) chegam via [webhooks](./2026-07-18-webhooks-e-eventos.md). --- ### `GET /v1/sessions/{id}/messages` — Listar mensagens da sessão **Query:** `cursor` (opcional, base64), `limit` (1–100, default 50), `direction` (opcional). **Resposta 200 — item** | Campo | Tipo | |-------|------| | `id` | uuid | | `direction` | string | | `from_normalized` | string | | `to_normalized` | string | | `content_type` | string | | `content_text` | string \| null | | `status` | string | | `created_at` | string | ```json { "data": [ { "id": "uuid", "direction": "outbound", "from_normalized": "+5511...", "to_normalized": "+5511...", "content_type": "text", "content_text": "olá", "status": "sent", "created_at": "ISO8601" } ], "pagination": { "next_cursor": null }, "error": null } ``` **Erros:** 400, 401. --- ### `GET /v1/messages/{id}` — Buscar mensagem por ID **Resposta 200 — campos:** `id`, `session_id`, `external_message_id`, `direction`, `from_normalized`, `to_normalized`, `content_type`, `content_text`, `content` (JSONB), `status`, `status_reason`, `media_asset_id`, `content_media_mime_type`, `content_media_size_bytes`, `sent_at`, `delivered_at`, `read_at`, `failed_at`, `created_at`. Use `media_asset_id` com `GET /v1/media/{id}` para renovar `signed_url` de mídia inbound. ### Como o console interpreta status / `status_reason` A página **Messages** do console mapeia 1:1 os campos da API (não usa aliases inventados): | Campo API | Uso no console | |-----------|----------------| | `to_normalized` / `from_normalized` | Coluna FROM/TO | | `content_text` | Preview + drawer | | `status` | Coluna STATUS (`queued`→retry visual; `failed`→erro) | | `status_reason` | Banner + linha **motivo** no drawer | Labels humanas de `status_reason` (mesmo enum do webhook `message.failed.reason`): | `status_reason` | Texto | |-----------------|-------| | `recipient_not_on_whatsapp` | Destino sem WhatsApp | | `session_disconnected` | Sessão desconectada | | `media_error` | Erro de mídia | | `unknown` / ausente | Motivo não informado pelo engine | Runbook operacional: [docs/agent-output/2026-07-19-runbook-mensagens-erros.md](../agent-output/2026-07-19-runbook-mensagens-erros.md). Catálogo completo de eventos: [webhooks](./2026-07-18-webhooks-e-eventos.md). **Erros típicos:** 401, 404. --- ## 4. Mídia ### `POST /v1/media/upload` — Upload de mídia Faz upload e devolve `id` (`media_id`) para usar no envio. **multipart/form-data:** campo do arquivo = **`file`** (aceita o primeiro arquivo do multipart). **MIME types aceitos (upload / outbound):** | kind | MIMEs | Max default | |------|-------|-------------| | image | jpeg, png, webp, gif, heic, heif | 5 MB | | audio | mpeg, ogg, opus, mp4, aac, webm, wav, amr, 3gpp, application/ogg | 16 MB | | video | mp4, webm, 3gpp, quicktime | 16 MB | | document | pdf, doc, docx, xlsx | 100 MB | **Inbound (voice notes do WhatsApp):** tipicamente `.oga` / `audio/ogg; codecs=opus`. O engine aceita esses formatos (e fallbacks por extensão/MIME declarado) ao persistir `media_id` para STT. Sempre use `media_id` + `GET /v1/media/{id}` — não dependa de URLs internas do WAHA. Hard cap do body: `MEDIA_MAX_UPLOAD_BYTES` (default 100 MB). **Resposta 200** ```json { "data": { "id": "uuid", "mime_type": "image/jpeg", "size_bytes": 12345, "signed_url": "https://...", "expires_at": "ISO8601" }, "error": null } ``` **Erros:** 400 (`INVALID_MEDIA_MIME`), 401, 403 (`QUOTA_EXCEEDED_STORAGE`), **413** (`MEDIA_TOO_LARGE`). `signed_url` TTL default **24h** (`MEDIA_SIGNED_URL_TTL_SECONDS=86400`); `GET /v1/media/{id}` **regenera** a URL. Retenção do objeto: `media_retention_days` do tenant (default 90), aplicada pelo worker quando `MEDIA_RETENTION_WORKER_ENABLED=true`. --- ### `POST /v1/media/upload-url` — Upload a partir de URL pública O engine baixa a mídia **server-side** e devolve o mesmo shape do multipart (útil para carrossel / CDN). **Body** ```json { "url": "https://cdn.exemplo.com/foto.jpg", "file_name": "opcional.jpg" } ``` | Campo | Obrigatório | Notas | |-------|-------------|--------| | `url` | Sim | **HTTPS** público; IPs privados / localhost bloqueados (anti-SSRF) | | `file_name` | Não | Override do nome; senão usa o path da URL | **Erros:** 400 (`INVALID_MEDIA_URL`, `INVALID_MEDIA_MIME`), 401, 403, 413, **502** (`MEDIA_FETCH_FAILED`). --- ### `GET /v1/media/{id}` — Buscar mídia por ID Mesmo shape de `data` do upload (`id`, `mime_type`, `size_bytes`, `signed_url`, `expires_at`). **Erros:** 400, 401, 404. --- ## 5. Tenant / Webhook ### `GET /v1/tenants/me` — Conferir webhook (equivalente a webhook/find) ```json { "data": { "id": "uuid", "slug": "slug", "webhook_url": "https://...|null", "webhook_secret_prefix": "umse…|null" }, "error": null } ``` Nunca devolve o segredo completo. Use no watchdog: se `webhook_url` divergir, faça PATCH; caso contrário, não mexa. **Erros:** 401, 404. --- ### `PATCH /v1/tenants/me` — Atualizar webhook do tenant **Body** | Campo | Tipo | Obrigatório | Notas | |-------|------|-------------|--------| | `webhook_url` | URI | **Sim** | HTTPS recomendado | | `webhook_secret` | string 8–255 | Não | Se **omitido**, **preserva** o segredo atual (só gera se ainda não existir). Envie o campo só quando quiser rotacionar. | **Resposta 200** ```json { "data": { "id": "uuid-do-tenant", "slug": "slug-do-tenant", "webhook_url": "https://...", "webhook_secret_prefix": "umse…" }, "error": null } ``` **Erros:** 400, 404. Só existe **1 webhook URL por tenant** no v1. Não há CRUD de múltiplos endpoints. Replay operacional de evento na DLQ: `POST /admin/webhooks/:id/retry` (Bearer do tenant) — não é listagem pública de DLQ. **Padrão watchdog (anti-drift):** `GET /v1/tenants/me` → se URL ok, não faça nada; se divergir, `PATCH` **sem** `webhook_secret` (idempotente, não rotaciona HMAC). Detalhes de entrega: [Webhooks e eventos](./2026-07-18-webhooks-e-eventos.md). --- ## 6. Mapa rápido (todos os paths do OpenAPI) | Método | Path | Resumo | |--------|------|--------| | POST | `/v1/sessions` | Criar sessão | | GET | `/v1/sessions` | Listar sessões | | GET | `/v1/sessions/{id}` | Detalhar sessão | | DELETE | `/v1/sessions/{id}` | Revogar sessão | | GET | `/v1/sessions/{id}/qr` | QR da sessão | | POST | `/v1/sessions/{id}/reconnect` | Reconectar (com ou sem QR) | | POST | `/v1/sessions/{id}/messages` | Enviar mensagem | | GET | `/v1/sessions/{id}/messages` | Listar mensagens | | GET | `/v1/messages/{id}` | Buscar mensagem | | POST | `/v1/media/upload` | Upload multipart | | POST | `/v1/media/upload-url` | Upload por URL pública | | GET | `/v1/media/{id}` | Buscar mídia | | GET | `/v1/tenants/me` | Conferir webhook | | PATCH | `/v1/tenants/me` | Atualizar webhook | | GET | `/v1/health` | Health (operacional; fora do OpenAPI) | Extras descobertos pelo console (não são paths deste OpenAPI, mas URLs úteis): | URL | Uso | |-----|-----| | `/v1/openapi.json` | Spec máquina | | `/v1/docs` | Swagger UI | --- ## 7. O que **não** está nesta API (v1) Não invente rotas. Hoje **não existem** no OpenAPI: - Administração de grupos (criar grupo, alterar admins, entrar por convite) — participar de grupos existentes **é** suportado (opt-in por sessão) - Botões / listas / templates HSM / enquetes - Localização, contato vCard, reações, presença (“digitando”) - Consulta pública de DLQ de webhooks (existe retry admin + coluna DLQ) - Provisionamento de tenant via API pública (use `/signup` no console + aprovação) **Já implementado no engine (Hermes upgrades 2026-07-18):** - Rate limit HTTP: `429` + `Retry-After` + `RATE_LIMITED` (default 60 req/s por tenant; desliga com `RATE_LIMIT_ENABLED=false`) - Rotação de API key: via suporte OneClient (nova key aparece one-shot no console) - Idempotência permanente por `idempotency_key` (sem TTL) - Paginação: `cursor` + `limit` (sessions default 20 max 100; messages default 50 max 100) - `POST /v1/sessions/{id}/reconnect` (tenta sem QR; senão devolve QR) - `POST /v1/media/upload-url` (URL pública HTTPS, anti-SSRF) - `GET /v1/tenants/me` + PATCH que **preserva** `webhook_secret` se omitido Ver matriz completa em [Capacidades e limites](./2026-07-18-capacidades-e-limites.md). --- ## 8. Relação com o Console (BFF) O console Next.js (`/api/sessions`, `/api/messages/send`, …) **não** é a API pública. Ele: 1. Autentica humano via Supabase. 2. Resolve a API key do tenant no banco. 3. Faz proxy para os mesmos `/v1/*` acima. Integradores devem falar **só** com `https://wpp-api.oneclient.tech/v1/*`. --- ## 9. Grupos Grupos são **opt-in por sessão**. Com `groups.enabled: false` (default) todo inbound `@g.us` continua sendo descartado, exatamente como antes desta feature. `@broadcast` é sempre descartado. ### Objeto `groups` | Campo | Tipo | Default | Notas | |-------|------|---------|-------| | `enabled` | boolean | `false` | Chave mestra da sessão | | `notify` | `none` \| `mentions` \| `all` | `none` | `none` = silencioso (só envio via API); `mentions` = webhook só com menção (`@`) ou reply à mensagem do próprio número; `all` = toda mensagem | | `digest.enabled` | boolean | `false` | Resumo periódico por LLM | | `digest.time` | `HH:MM` (24h) | `20:00` | Horário local do disparo | | `digest.timezone` | IANA | `America/Sao_Paulo` | Fuso usado para `time` | Os três cenários típicos: **silencioso + injeção externa** = `enabled: true` + `notify: "none"`; **responder quando marcado** = `notify: "mentions"`; **resumo diário** = `digest.enabled: true`. Além da política da sessão existe uma **allowlist por grupo**: só grupos registrados e habilitados são processados. Grupos vistos pela primeira vez são registrados como descobertos (desabilitados) e não geram webhook. ### Endpoints | Método | Path | Uso | |--------|------|-----| | `GET` | `/v1/sessions/{id}/groups` | Lista grupos do WhatsApp da sessão combinados com a allowlist | | `PUT` | `/v1/sessions/{id}/groups/{jid}` | Registra/atualiza grupo: `{ enabled, name, notify, digest }` (`notify`/`digest` `null` = herda da sessão) | | `DELETE` | `/v1/sessions/{id}/groups/{jid}` | Remove da allowlist (204) | | `GET` | `/v1/sessions/{id}/groups/{jid}/messages` | Histórico capturado (`limit`, `since`, `until`) | | `GET` | `/v1/sessions/{id}/groups/{jid}/digests` | Resumos gerados (`limit`) | `{jid}` é o JID do grupo (`120363...@g.us`), URL-encoded. ### Envio a grupo `POST /v1/sessions/{id}/messages` com `to_group` no lugar de `to`. Requisitos: sessão conectada, `groups.enabled: true` e grupo habilitado na allowlist. Além da quota diária do tenant existe um **teto por grupo** (`GROUP_DAILY_SEND_CAP`, default 20/dia) — estourar devolve `403 GROUP_DAILY_CAP_EXCEEDED`. Erros específicos: `GROUPS_DISABLED`, `GROUP_NOT_ALLOWED`, `INVALID_GROUP_JID`, `GROUP_DAILY_CAP_EXCEEDED`. ### Eventos `group.message.received`, `group.message.sent`, `group.digest.ready` — detalhes em [Webhooks](./2026-07-18-webhooks-e-eventos.md). ### Retenção `group_messages` tem retenção curta configurável (`GROUP_MESSAGES_RETENTION_DAYS`, default 30 dias): volume de grupo é alto e os dados são de terceiros. O resumo (`group_digests`) permanece. --- ## Links - [Guia de integração](./2026-07-18-guia-de-integracao.md) - [Webhooks](./2026-07-18-webhooks-e-eventos.md) - [Índice](./README.md) - OpenAPI: https://wpp-api.oneclient.tech/v1/openapi.json --- # Webhooks e eventos # 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 < 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) --- # Capacidades e limites # Capacidades e limites — wpp-engine v1 > Documento honesto para **colocar o produto em ação** com clientes: o que vender, o que não prometer, e o que alinhar com o time (Lucas / backend). > **Data:** 2026-07-18 > Fontes: OpenAPI de produção, health check, audit `docs/status-report-wpp-engine-2026-07-08.md`, ADR-006, quickstart do console. --- ## 1. O produto está funcional? **Sim, para o núcleo v1**, com ressalvas claras: | Verificação (2026-07-19) | Resultado | |--------------------------|-----------| | Engine `GET /v1/health` | `status: ok`, versão `0.1.0` | | Rotas protegidas sem key | `401` (auth ativa) | | OpenAPI publicado | 11 paths de negócio + docs/Swagger | | Console + BFF | Sessões, mensagens, webhook config, API key one-shot | | Onboarding de tenant | **Self-service com aprovação** (`/signup` → admin aprova → credenciais) | | Rate limit / DLQ API | Rate limit **sim** (`429`); DLQ listagem pública **não** | | Grupos / botões / templates | Grupos: **opt-in por sessão** (default desligado, inbound ignorado); botões/templates: **não** | Conclusão prática: **pode ir a produção com clientes**, desde que o contrato comercial e o adapter do produto (ex. Clinik.One) respeitem a matriz abaixo — especialmente **canal não oficial** e o fato de grupos serem participação passiva/opt-in, não administração de grupos. --- ## 2. Matriz — o que PODE hoje | Capacidade | Status | Como | |------------|--------|------| | Autenticar com API key | Sim | `Authorization: Bearer wae_live_*` | | Criar sessão + QR | Sim | `POST /v1/sessions` | | Renovar QR | Sim | `GET /v1/sessions/{id}/qr` | | Listar / detalhar / revogar sessão | Sim | GET list, GET id, DELETE | | Enviar texto | Sim | `content.type = text` (até 4096) | | Enviar imagem / áudio / vídeo / documento | Sim | Upload → `media_id` → send; áudio = PTT (voice); MP3→OGG/Opus automático no outbound | | Receber mídia no webhook | Sim | `content_type`, `media_id`, `mime_type`, `signed_url` | | Idempotência de envio | Sim | `idempotency_key` 8–120 (permanente) | | Listar / buscar mensagens | Sim | GET session messages / GET message (com `media_asset_id`) | | Webhook único por tenant | Sim | `PATCH /v1/tenants/me` | | Eventos de mensagem + sessão | Sim | received/sent/delivered/read/failed + session.* | | Assinatura HMAC | Sim | SHA-256 hex do raw body | | Confirmação sent / delivered / read / failed | Sim | Via webhooks (`failed.reason` enum estável) | | Rate limit HTTP | Sim | `429` + `Retry-After`. Três camadas: 60 req/s por tenant, 6.000 req/min por IP, 20 falhas de auth por IP a cada 10 min | | Filtro de grupos inbound | Sim | Default descarta `@g.us`; `@broadcast` sempre descartado | | Participar de grupos (opt-in) | Sim | `groups` na sessão + allowlist por grupo (`PUT /v1/sessions/{id}/groups/{jid}`) | | Enviar para grupo | Sim | `to_group` em `POST /v1/sessions/{id}/messages` (teto diário próprio) | | Webhook só quando marcado no grupo | Sim | `groups.notify = mentions` → `group.message.received` | | Resumo diário de grupo por LLM | Sim | `groups.digest` → `group.digest.ready` | | Reconectar sessão | Sim | `POST /v1/sessions/{id}/reconnect` (sem QR se auth válida; senão QR) | | Upload por URL pública | Sim | `POST /v1/media/upload-url` (HTTPS, anti-SSRF) | | Conferir webhook (GET) | Sim | `GET /v1/tenants/me` — watchdog: PATCH só em drift | | Rotação de API key | Sim | Via suporte OneClient (one-shot no console) | | Multi-tenant (1 key por cliente) | Sim | Isolamento por tenant | | Health / OpenAPI / Swagger | Sim | `/v1/health`, `/v1/openapi.json`, `/v1/docs` | | Console + signup com aprovação | Sim | `/signup`, login, QR, key one-shot, config webhook | --- ## 3. Matriz — o que NÃO PODE hoje | Capacidade | Status | Nota comercial | |------------|--------|----------------| | **Administrar grupos (criar, admins, convite)** | Não | Participar de grupo existente é suportado; criar/administrar não | | Botões interativos / listas | Não | Não prometa UI de botões | | Templates HSM / Meta Cloud oficial | Não | Canal é **não oficial** (WAHA sob o engine) | | Enquetes, reações, localização, contato | Não | — | | “Digitando…” / presença | Não | — | | Verificar se um número tem WhatsApp | Não | — | | Disconnect dedicado (stop sem revogar) | Não | Use DELETE para revogar; reconnect para voltar | | Múltiplas API keys / rotação self-service | Parcial | 1 key; rotação via suporte OneClient | | Criar tenant via API pública | Não | Cadastro em `/signup` com aprovação (sem endpoint público de provisão) | | Múltiplos webhooks | Não | 1 URL | | Replay / DLQ via API pública | Parcial | Retry admin existe; UI DLQ do console é mock | | SDK oficial | Não | REST puro (como muitos usam Evolution) | --- ## 4. Comparativo honesto (Evolution API · Z-API · wpp-engine) Comparação **de posicionamento**, não de feature-parity pixel a pixel. Use para conversa com cliente e para decidir o adapter. | Tema | Evolution API (típico) | Z-API (típico) | **wpp-engine v1** | |------|------------------------|----------------|-------------------| | Modelo | Self-host / cloud de terceiros | SaaS brasileiro | SaaS OneClient (engine gerenciado) | | Auth | Instance + apikey / token | Instance + token | Bearer `wae_live_*` por tenant | | Sessão / QR | Sim | Sim | Sim | | Texto + mídia | Sim | Sim | Sim (upload multipart **ou** URL → media_id) | | Grupos | Em geral sim (amplo) | Em geral sim | Participação opt-in (ler, enviar, resumir) — **sem administração** | | Botões / listas | Frequentemente sim | Frequentemente sim | **Não** | | Webhooks + status de mensagem | Sim | Sim | Sim (HMAC próprio) | | Multi-tenant nativo | Depende do deploy | Conta/instâncias | Sim (tenant + RLS no backend) | | Console | Varia | Painel web | Console Next.js + signup com aprovação | | Canal | Não oficial | Não oficial | Não oficial (WAHA atrás do Fastify) | | Rate limit / DLQ | Varia | Varia | Rate limit **sim** (60/s); DLQ listagem pública **não** | | OpenAPI | Varia | Parcial | **Sim** (`/v1/openapi.json`) | **Quando wpp-engine é a escolha certa** - Você quer um **fornecedor OneClient** com tenant isolado, envelope de erro padrão e HMAC. - O caso de uso é **1:1** (paciente ↔ clínica): texto + mídia + confirmação de entrega. - Prefere **API gerenciada** a operar Evolution self-host. **Quando NÃO é a escolha certa (ainda)** - O cliente exige **administração de grupos** (criar grupo, mexer em admins), botões ou features ricas de “WhatsApp marketing”. - O compliance exige **Cloud API oficial Meta** (templates aprovados, WABA). - Precisa de **self-service total** (criar conta, girar key, DLQ) sem intervenção humana. --- ## 5. Riscos do canal não oficial (política de uso) O motor sob o engine é baseado em stack tipo WAHA / web não oficial. Isso implica: 1. **Risco de ban / restrição** do número pela Meta — inerente ao modelo. 2. **1 sessão ≈ 1 número ≈ processo vivo** — sessão cai → precisa QR de novo (`session.disconnected`). 3. **Não use** para spam, cold blast massivo ou listas compradas. 4. **Boas práticas sugeridas:** - Chip / número dedicado ao produto. - Warm-up gradual de volume. - Conteúdo conversacional (respostas a opt-in). - Monitorar `session.disconnected` e `message.failed`. - Separar números de marketing vs. atendimento se o volume crescer. 5. Para fluxos que **exijam** oficial (HSM, WABA), planejar caminho paralelo Meta Cloud API — fora deste produto v1. --- ## 6. Como integrar nos produtos OneClient (ex.: Clinik.One) Recomendação do audit interno (2026-07-08), ainda válida: 1. Adapter HTTP → `https://wpp-api.oneclient.tech/v1/*` com Bearer por clínica/tenant. 2. Persistir `tenant_id`, `session_id` e a API key cifrada. 3. Receptor de webhook com HMAC + dedupe por `event.id`. 4. Para Whisper: use `data.media_id` do `message.received` → `GET /v1/media/{id}`. 5. Rate limit já existe no engine (ainda assim, fila no produto é boa prática). 6. Onboarding de nova clínica: `/signup` → aprovação no admin → credenciais; rotação de key: suporte OneClient. 7. Watchdog: `GET /v1/tenants/me` periódico; `PATCH` **só** se `webhook_url` divergir (e **sem** enviar `webhook_secret`). 8. Sessão caiu: preferir `POST .../reconnect` antes de alertar humano; se voltar `qr`, aí sim peça novo scan. 9. Carrossel/imagem por URL: `POST /v1/media/upload-url` → use o `media_id` no envio. Esforço estimado do adapter Hermes: **baixo–moderado** após o upgrade de 2026-07-18 (mídia no webhook + PTT já ok + grupos filtrados). --- ## 7. Pendências resolvidas (upgrade Hermes 2026-07-18) As perguntas antigas `[confirmar com o dev]` foram respondidas no código do `wpp-engine` e documentadas em: - [`docs/2026-07-18-respostas-integracao-hermes.md`](../2026-07-18-respostas-integracao-hermes.md) - Este upgrade (webhook media, GET message, filtro grupo, rate limit, retenção, rotate key, failed reason) **Ainda fora de escopo / mitigado no consumidor:** staging dedicado, ordem total de ACKs, API pública de listagem DLQ, botões como feature, administração de grupos. (Conversão de áudio outbound PTT: **feita no engine** via WAHA `convert/voice` desde 2026-07-20.) --- ## 8. Checklist de go-live com o primeiro cliente - [ ] Conta aprovada via `/signup` + key revelada com segurança no console - [ ] Webhook HTTPS + HMAC testado com evento real (incl. áudio inbound → `media_id`) - [ ] Sessão conectada e monitorada (`session.disconnected`) - [ ] Envio texto + áudio PTT (MP3→OGG automático) + imagem validados **no celular** - [ ] Confirmações `delivered`/`read`/`failed` refletidas no produto - [ ] Contrato comercial alinhado: **grupos só por opt-in (sem administração) / sem botões / canal não oficial** - [ ] Se usar grupos: política (`notify`) e allowlist revisadas, teto diário por grupo aceito, retenção de `group_messages` alinhada com LGPD - [ ] Contato operacional OneClient para rotação de key e recuperação de sessão - [ ] Volume inicial controlado (sem blast); rate limit 60/s no engine --- ## Links - [Índice da documentação](./README.md) - [Guia de integração](./2026-07-18-guia-de-integracao.md) - [Referência API v1](./2026-07-18-referencia-api-v1.md) - [Webhooks e eventos](./2026-07-18-webhooks-e-eventos.md) - Audit: [`docs/status-report-wpp-engine-2026-07-08.md`](../status-report-wpp-engine-2026-07-08.md) - ADR-006: [`docs/decisions/2026-06-10-adr-006-modo-concierge.md`](../decisions/2026-06-10-adr-006-modo-concierge.md)