Referência da API v1 — wpp-engine
Fonte primária: código do
wpp-engine+ OpenAPIhttps://wpp-api.oneclient.tech/v1/openapi.json(versão0.1.0)
Servers:https://wpp-api.oneclient.tech·http://localhost:3000(dev)
Auth scheme: HTTP Bearer (bearerAuth— API Keywae_live_*)
Data: 2026-07-18 (atualizado com upgrade Hermes)
Todas as respostas de sucesso usam o envelope:
{ "data": { ... }, "error": null }
Listagens paginadas incluem:
{ "data": [ ... ], "pagination": { "next_cursor": null }, "error": null }
Erros:
{ "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.
curl -sS -X POST "https://wpp-api.oneclient.tech/v1/tenants/me/rotate-key" \
-H "Authorization: Bearer $WPP_API_KEY"
{
"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):
{
"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
{
"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
{
"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
{
"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
{
"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):
{
"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
{ "type": "text", "text": "olá" }
text: 1–4096 caracteres.
content — imagem
{ "type": "image", "media_id": "uuid", "caption": "opcional ≤ 1024" }
content — áudio
{ "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
{ "type": "video", "media_id": "uuid", "caption": "opcional ≤ 1024" }
content — documento
{ "type": "document", "media_id": "uuid", "caption": "opcional ≤ 1024" }
Resposta 200
{
"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.
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 |
{
"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. Catálogo completo de eventos: webhooks.
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
{
"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
{ "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)
{
"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
{
"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.
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
/signupno 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 comRATE_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 preservawebhook_secretse omitido
Ver matriz completa em Capacidades e limites.
8. Relação com o Console (BFF)
O console Next.js (/api/sessions, /api/messages/send, …) não é a API pública. Ele:
- Autentica humano via Supabase.
- Resolve a API key do tenant no banco.
- 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.
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.