# Catcher — WhatsApp API · manual completo > API brasileira de WhatsApp (não-oficial) para desenvolvedores e agentes de IA: mensagens sem templates pré-aprovados, sem janela de 24 horas e sem cobrança por mensagem, com proteção anti-ban em camadas (IP residencial dedicado por instância), webhooks assinados e eventos em tempo real (SSE/WebSocket). Base da API: https://api.catcher.one/v1 — auth via header X-API-Key (token bza_...). Instâncias conectam via QR code. Teste grátis por 30 dias. > > Este arquivo é a documentação inteira da API em um único texto (gerado do > mesmo manual que alimenta https://catcher.one/docs). Índice curto: https://catcher.one/llms.txt --- # BiaZap API - Manual Completo > **Base URL:** `https://api.catcher.one` > **Versão:** v1 > **Formato:** JSON (`Content-Type: application/json`) > **Desenvolvimento:** `http://localhost:8088` --- ## Índice 1. [Autenticação](#1-autenticação) 2. [Health & Sistema](#2-health--sistema) 3. [Auth (Registro e Login)](#3-auth-registro-e-login) 4. [Tokens de API](#4-tokens-de-api) 5. [Usuários](#5-usuários) 6. [Instâncias WhatsApp](#6-instâncias-whatsapp) 7. [Mensagens](#7-mensagens) 8. [Mensagens Agendadas](#8-mensagens-agendadas) 9. [Mídia (Upload/Download)](#9-média-uploaddownload) 10. [Chats](#10-chats) 11. [Contatos](#11-contatos) 12. [Presença e Perfil](#12-presença-e-perfil) 13. [Grupos](#13-grupos) 14. [Fila de Mensagens](#14-fila-de-mensagens) 15. [Webhooks](#15-webhooks) 16. [Eventos em Tempo Real (SSE/WebSocket)](#16-eventos-em-tempo-real-ssewebsocket) 17. [Administração (Superadmin)](#17-administração-superadmin) 18. [Uso e Limites](#18-uso-e-limites) 19. [Códigos de Erro](#19-códigos-de-erro) 20. [Números Brasileiros](#20-números-brasileiros) 21. [Conflito de Número Duplicado](#21-conflito-de-número-duplicado) 22. [Identificadores e Correlação de Eventos](#22-identificadores-e-correlação-de-eventos) 23. [Billing](#23-billing) --- ## 1. Autenticação A API suporta dois métodos de autenticação: ### JWT (Bearer Token) Obtido via `/v1/auth/login`. Enviar no header: ``` Authorization: Bearer ``` - Algoritmo: HS256 - Expiracao padrão: 15 minutos - Claims: `company_id`, `user_id`, `role`, `is_superadmin` - **Revalidacao de empresa:** em cada request autenticada com JWT (exceto `is_superadmin`), o servidor consulta o master DB para garantir que a empresa existe e está `active`. Empresa inexistente → `403` com `company not found`; suspensa → `403` com `company suspended`; falha de banco nessa consulta → `503` com `service unavailable`. O mesmo critério de empresa ativa se aplica a **API key** (token inválido contínua `401`). ### API Key Obtida via `/v1/tokens` ou retornada no registro (`POST /v1/auth/register`). Enviar no header: ``` X-API-Key: bza_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` - Formato: `bza_` + 60 caracteres hex - Armazenada como hash SHA256 no banco (nunca em texto plano) - Pode ser revogada a qualquer momento > **⚠️ ATENÇÃO — NÃO CONFUNDIR OS HEADERS:** > > | Tipo de credencial | Header correto | Exemplo | > |---|---|---| > | JWT (sessão browser) | `Authorization: Bearer ` | `Authorization: Bearer eyJhbG...` | > | API Key (integração/SDK) | `X-API-Key: ` | `X-API-Key: bza_4615fb31...` | > > **Usar `Authorization: Bearer bza_...` retorna `401 TOKEN_INVALID`** — o middleware JWT tenta decodificar o `bza_` como JWT e falha. O token `bza_` DEVE ser enviado via `X-API-Key`. > > O token retornado no `POST /v1/auth/register` é um **API key** (`bza_...`), NÃO um JWT. Use `X-API-Key` para todas as chamadas subsequentes com esse token. ### Roles (Papeis) | Role | Descricao | |------|-----------| | `owner` | Dono da empresa. Acesso total: usuários, tokens, instâncias, mensagens | | `admin` | Administrador. Gerencia instâncias, webhooks, filas | | `agent` | Agente. Envia mensagens, acessa chats/contatos | | `superadmin` | Administrador da plataforma. Acesso a rotas `/v1/admin/*` | ### Acesso por Role | Recurso | owner | admin | agent | |---------|:-----:|:-----:|:-----:| | Tokens e Usuários | x | - | - | | Instâncias (CRUD) | x | x | - | | Mensagens | x | x | x | | Chats e Contatos | x | x | x | | Presença e Perfil | x | x | x | | Grupos | x | x | x | | Mídia | x | x | x | | Fila | x | x | - | | Webhooks | x | x | - | | Admin | superadmin only | - | - | ### Rate Limiting - Baseado em Redis, por empresa - Limite configuravel por plano (padrão: 60 req/min) - Rotas `/v1/admin/*` não aplicam throttling adicional por request; o acesso depende de JWT válido com `is_superadmin=true` - Headers de resposta: - `X-RateLimit-Limit` - Total permitido por minuto - `X-RateLimit-Remaining` - Restantes no periodo - `X-RateLimit-Reset` - Timestamp Unix do reset - `Retry-After` - Segundos para aguardar (quando 429) --- ## 2. Health & Sistema ### GET /health Verifica se o serviço e suas dependencias (MySQL, Redis) estão funcionando. **Auth:** Nenhuma **Resposta 200:** ```json { "status": "ok", "service": "biazap-api", "checks": { "mysql": "ok", "redis": "ok" } } ``` **Resposta 503 (degradado):** ```json { "status": "degraded", "service": "biazap-api", "checks": { "mysql": "ok", "redis": "error: connection refused" } } ``` --- ### GET /ready Verifica se todas as dependencias (MySQL, Redis, S3) estão funcionando. **Auth:** Nenhuma **Resposta 200:** ```json { "status": "ready", "checks": { "mysql": "ok", "redis": "ok", "s3": "ok" } } ``` **Resposta 503 (degradado):** ```json { "status": "degraded", "checks": { "mysql": "ok", "redis": "error" } } ``` --- ### GET /metrics Metricas Prometheus. **Auth:** Desabilitado por padrão quando `METRICS_BEARER_TOKEN` estiver vazio. Para expor: - defina `METRICS_BEARER_TOKEN` e envie `Authorization: Bearer ` - ou habilite explicitamente `ALLOW_PUBLIC_METRICS=true` Se `ALLOW_PUBLIC_METRICS=false` e `METRICS_BEARER_TOKEN` estiver vazio, a API responde `401`. **Resposta:** Texto no formato Prometheus exposition. **Exemplos de series expostas (não exaustivo):** `biazap_active_instances`, `biazap_connection_states`, `biazap_messages_total`, `biazap_send_latency_seconds_*`, `biazap_webhook_failures_total`, `biazap_send_failures_total`, `biazap_throttle_pauses_total`, `biazap_realtime_events_dropped_total` (eventos descartados quando o cliente SSE/WS não acompanha o hub), `biazap_queue_depth`, `biazap_redis_latency_seconds_*`, `biazap_s3_latency_seconds_*`. **Stealth / fingerprint hardening (scaffolding inicial):** - `biazap_send_interval_ms` - `biazap_connection_opens_total` - `biazap_connection_closes_total` - `biazap_warmup_phase` - `biazap_warmup_daily_sent` - `biazap_warmup_daily_cap` - `biazap_fingerprint_platform_total` - `biazap_fingerprint_utls_spec_total` - `biazap_fingerprint_utls_pool_active_size` - `biazap_fingerprint_utls_assignments_total` - `biazap_fingerprint_utls_probe_total` - `biazap_fingerprint_utls_burn_total` - `biazap_fingerprint_city_total` - `biazap_hygiene_applied_total` - `biazap_send_blocked_total` - `biazap_humanize_typing_emitted_total` - `biazap_stealth_debug_mode_active` - `biazap_ban_events_total` - `biazap_audit_emit_failed_total` - `biazap_audit_emit_dropped_total` - `biazap_appversion_fetch_failed_total` As series do Stealth Mode aparecem no `/metrics` desde o bootstrap, mesmo antes dos produtores definitivos, para que dashboards e canaries possam ser preparados sem esperar as tasks posteriores. Em `biazap_send_blocked_total`, o label `reason` agora inclui `inbound_first` quando a instância opt-in recusa um outbound para chat sem inbound previo na janela configurada. --- ## 3. Auth (Registro e Login) ### POST /v1/auth/register Cria uma nova empresa (tenant) com o usuário owner. **Auth:** Nenhuma **Request:** ```json { "company_name": "Minha Empresa", "owner_email": "dono@empresa.com", "owner_name": "Joao Silva", "password": "MinhaSenh@123" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `company_name` | string | sim | Nome da empresa | | `owner_email` | string | sim | Email do owner | | `owner_name` | string | sim | Nome do owner | | `password` | string | sim | Senha (mínimo 12 caracteres) | **Resposta 201:** ```json { "company_id": 1, "token": "bza_xxxx...xxxx", "api_token": "bza_xxxx...xxxx", "token_id": 1, "last4": "xxxx", "slug": "minha-empresa", "email_verification_required": true, "message": "company registered successfully" } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `company_id` | **integer** | ID numerico da empresa criada (ex: `1`, `38`, `42` — NÃO é string) | | `token` | string | API token bootstrap `bza_...` — mesmo valor que `api_token` | | `api_token` | string | Alias de `token` (mesmo valor, mantido por retrocompatibilidade) | | `token_id` | integer | ID interno do token no banco | | `last4` | string | Últimos 4 caracteres do token (para exibição segura) | | `slug` | string | Slug URL-safe derivado do `company_name` | | `email_verification_required` | boolean | `true` quando o owner deve confirmar o email com o código 6 dígitos enviado por Resend (sempre que o registro usou senha). Integrações que não renderizam UI podem ignorar — `POST /v1/instances` e `/v1/tokens` e `/v1/webhooks` retornam 403 `EMAIL_NOT_VERIFIED` até que `POST /v1/auth/verify-email` seja chamado. | | `message` | string | Mensagem de confirmação | > **⚠️ `company_id` é INTEGER, não string.** JSON retorna `"company_id": 38` (número sem aspas). Tipagens de DTO que usem `string` para este campo falharão silenciosamente no `json.Unmarshal` / `JSON.parse` com erro de tipo. Use `int`, `int64`, ou `number`. > O `token` retornado no registro e um **API token bootstrap** (`bza_...`), não um JWT de sessão do browser. Para chamadas subsequentes, envie via `X-API-Key: bza_...` (ver seção Autenticação acima). Para iniciar a sessão web, chame `POST /v1/auth/login`. **Erros:** | Status | Código | Body | Descricao | |--------|--------|------|-----------| | `400` | `VALIDATION_ERROR` | `{"error":"password must be at least 12 characters","error_code":"VALIDATION_ERROR"}` | Campos faltando ou senha curta | | `409` | `EMAIL_ALREADY_REGISTERED` | `{"error":"email already registered","error_code":"EMAIL_ALREADY_REGISTERED"}` | Email já usado por outra empresa | | `409` | `SLUG_TAKEN` | `{"error":"company name already taken","error_code":"SLUG_TAKEN"}` | Slug do `company_name` já existe | > **Integrações automatizadas** (como o Catcher) que provisionam multiplas empresas com o mesmo email humano devem usar plus-addressing (`user+suffix@domain.com`) ou emails únicos por tenant para evitar `EMAIL_ALREADY_REGISTERED`. --- ### POST /v1/auth/login Autêntica um usuário e retorna JWT. **Auth:** Nenhuma **Request:** ```json { "email": "dono@empresa.com", "password": "MinhaSenh@123" } ``` **Resposta 200:** ```json { "token": "eyJhbGciOiJIUzI1NiIs...", "company_id": 1, "user_id": 1, "role": "owner", "email": "dono@empresa.com", "is_superadmin": false } ``` **Cookies de sessão enviados no login bem-sucedido:** - `biazap_refresh_token`: `HttpOnly`, usado para rotação de sessão - `biazap_csrf_token`: usado no header `X-CSRF-Token` em requests mutaveis quando o cookie de refresh estiver presente **Protecoes adicionais:** - lockout por conta após 5 falhas em 15 minutos - JWT HS256 com `jti` e revogacao em logout - refresh token com rotação a cada uso **Erros:** - `401` - Credenciais inválidas - `403` - Empresa suspensa - `429` - Conta temporariamente bloqueada por excesso de tentativas --- ### GET /v1/auth/oauth/{provider}/start Inicia o login/cadastro social via OAuth 2.0. `provider` aceita `google` ou `github`. **Auth:** Nenhuma **Query params:** | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `mode` | string | não | `login` ou `signup`. Default: `login` | **Resposta 302:** redireciona para o provedor com `state`, `redirect_uri`, `client_id`, `response_type=code` e escopos: - Google: `openid email profile` - GitHub: `read:user user:email` **Erros JSON:** - `400 OAUTH_PROVIDER_INVALID` - provider diferente de `google` ou `github` - `503 OAUTH_PROVIDER_DISABLED` - provider sem `OAUTH__ENABLED=true`, `CLIENT_ID` ou `CLIENT_SECRET` - `503 SERVICE_UNAVAILABLE` - Redis indisponível para armazenar o `state` Quando o request e uma navegação de browser (`Accept: text/html`), esses erros redirecionam para `/login?oauth_error=` em vez de devolver JSON. --- ### GET /v1/auth/oauth/{provider}/callback Callback configurado no Google/GitHub. O backend válida `state`, troca `code` por access token, busca o perfil e exige email verificado. **Auth:** Nenhuma **Fluxo:** - Identidade OAuth existente: emite a mesma sessão do `POST /v1/auth/login` e redireciona para `/oauth/callback?provider=...`. - Email verificado já cadastrado: vincula automaticamente o provider ao usuário existente, emite sessão e redireciona para `/oauth/callback?provider=...`. - Email novo: cria um token pendente curto no Redis e redireciona para `/oauth/complete?provider=...&token=...`. **Cookies de sessão:** quando o callback conclui login de usuário existente, envia `biazap_refresh_token` e `biazap_csrf_token` com as mesmas regras do login por senha. **Erros via redirect:** falhas redirecionam para `/login?oauth_error=&provider=`. | Código | Quando ocorre | |--------|---------------| | `OAUTH_STATE_INVALID` | `state` ausente, expirado ou de outro provider | | `OAUTH_EMAIL_UNVERIFIED` | Provider não retornou email verificado | | `OAUTH_UPSTREAM_FAILED` | Falha no token exchange ou profile fetch | | `OAUTH_PROVIDER_INVALID` | Provider desconhecido | | `OAUTH_PROVIDER_DISABLED` | Provider desabilitado ou sem credenciais | --- ### POST /v1/auth/oauth/complete-signup Finaliza cadastro social novo pedindo apenas o nome da empresa. Cria empresa, tenant DB, usuário owner e vinculo OAuth; em seguida emite sessão browser. **Auth:** Nenhuma **Request:** ```json { "token": "pending_oauth_token", "company_name": "Minha Empresa" } ``` **Resposta 200:** mesmo payload de `POST /v1/auth/login`, mais cookies `biazap_refresh_token` e `biazap_csrf_token`. **Erros:** - `400 OAUTH_PENDING_INVALID` - token pendente inválido ou expirado - `400 BAD_REQUEST` - `company_name` inválido - `409 EMAIL_ALREADY_REGISTERED` - email foi cadastrado antes da conclusao - `409 CONFLICT` - nome/slug da empresa já existe - `500 REGISTRATION_FAILED` - falha ao provisionar empresa, usuário, token ou tenant **Variaveis de ambiente OAuth:** | Variavel | Descricao | |----------|-----------| | `OAUTH_FRONTEND_REDIRECT_URL` | Base do Console para redirects (`https://app.catcher.one`) | | `OAUTH_GOOGLE_ENABLED` / `OAUTH_GITHUB_ENABLED` | Habilita explicitamente cada provider (`true`/`false`) | | `OAUTH_GOOGLE_CLIENT_ID` / `OAUTH_GOOGLE_CLIENT_SECRET` | Credenciais Google | | `OAUTH_GOOGLE_REDIRECT_URL` | Callback cadastrado no Google (`https://api.../v1/auth/oauth/google/callback`) | | `OAUTH_GITHUB_CLIENT_ID` / `OAUTH_GITHUB_CLIENT_SECRET` | Credenciais GitHub | | `OAUTH_GITHUB_REDIRECT_URL` | Callback cadastrado no GitHub (`https://api.../v1/auth/oauth/github/callback`) | `*_AUTH_URL`, `*_TOKEN_URL`, `*_USER_INFO_URL` e `OAUTH_GITHUB_EMAILS_URL` existem para testes/overrides; em produção os defaults oficiais são usados. --- ### POST /v1/auth/refresh Rotaciona o refresh token e devolve um novo access token JWT. **Auth:** Cookie `biazap_refresh_token` **Headers recomendados:** ```http X-CSRF-Token: ``` **Resposta 200:** mesmo payload de `POST /v1/auth/login`. **Erros:** - `401` - Refresh token ausente, inválido ou expirado - `403` - Falha de CSRF quando houver cookie de refresh --- ### POST /v1/auth/logout Revoga o JWT atual, inválida o refresh token e limpa os cookies de sessão. **Auth:** JWT em `Authorization: Bearer ` **Headers recomendados:** ```http X-CSRF-Token: ``` **Resposta 204:** Sem corpo. --- ### POST /v1/auth/verify-email Válida o código de 6 dígitos enviado por email no momento do registro (ou por resend). Ao sucesso, marca `users.email_verified_at = NOW()`, limpa a state de verificação e reemite a sessão (mesmos cookies de `POST /v1/auth/login`). A resposta é idêntica ao login, mais o campo `email_verified: true`. **Auth:** JWT em `Authorization: Bearer ` (+ CSRF header quando via browser) **Request:** ```json { "code": "428193" } ``` **Resposta 200:** Idêntico ao login. ```json { "token": "", "company_id": 42, "user_id": 118, "role": "owner", "email": "dono@empresa.com", "is_superadmin": false, "email_verified": true } ``` **Erros:** | Status | Código | Quando | |---|---|---| | `400` | `EMAIL_VERIFICATION_CODE_INVALID` | Código com formato errado, ou não bate com o persistido. Contador `email_verification_attempts` é incrementado. | | `400` | `EMAIL_VERIFICATION_CODE_EXPIRED` | Código expirou (TTL de 30 minutos). Peça `resend` e tente de novo. | | `429` | `EMAIL_VERIFICATION_MAX_ATTEMPTS` | 5 tentativas erradas consecutivas — a state é limpa, obrigando `resend`. | | `409` | `EMAIL_ALREADY_VERIFIED` | Usuário já verificado (ex: outro tab). Cliente pode seguir direto. | --- ### POST /v1/auth/verify-email/resend Gera um novo código (invalidando qualquer anterior) e dispara o email via Resend. Respeita cooldown de 60 segundos entre requisições. **Auth:** JWT em `Authorization: Bearer ` **Resposta 202:** ```json { "message": "verification code sent", "cooldown_secs": 60, "expires_in": 1800 } ``` **Erros:** | Status | Código | Quando | |---|---|---| | `429` | `EMAIL_VERIFICATION_RESEND_COOLDOWN` | Enviou faz menos de 60 segundos. `Retry-After` header diz quantos segundos faltam. | | `409` | `EMAIL_ALREADY_VERIFIED` | Nada a reenviar. | --- ### PATCH /v1/auth/me Edita o nome do usuário autenticado e/ou o nome da empresa. Ambos os campos são opcionais (sem nenhum = no-op 200). `company_name` só é honrado para `role=owner`. **Auth:** JWT em `Authorization: Bearer ` **Request:** ```json { "name": "Renata Schelbauer", "company_name": "Catcher LTDA" } ``` **Resposta 200:** ```json { "user": { "id": 12, "email": "renata@catcher.one", "name": "Renata Schelbauer", "role": "owner" }, "company": { "id": 9, "name": "Catcher LTDA", "slug": "catcher-original-slug" } } ``` > **Slug não muda.** Mesmo se você renomear a empresa, o `slug` permanece o original (compatibilidade com integrações externas). **Erros:** | Status | Código | Quando | |---|---|---| | `400` | `BAD_REQUEST` | Campo vazio (após trim) ou maior que 255 caracteres | | `403` | `OWNER_ONLY` | Usuário não-owner tentou setar `company_name` | --- ### PATCH /v1/auth/me/password Troca a senha do usuário autenticado. Exige a senha atual + nova com mínimo 8 caracteres. Em sucesso, **revoga todas as sessões ativas** (refresh tokens) e bumpa `password_changed_at` (que inválida o JWT atual via claim `pwd_changed_at`). O JWT atual contínua válido até expirar (~15min); no próximo refresh o usuário é forçado a re-logar. **Auth:** JWT em `Authorization: Bearer ` **Request:** ```json { "current_password": "MinhaSenhaAtual", "new_password": "MinhaNovaSenha" } ``` **Resposta 200:** ```json { "message": "password updated", "requires_relogin": true } ``` **Erros:** | Status | Código | Quando | |---|---|---| | `400` | `PASSWORD_TOO_SHORT` | `new_password` com menos de 8 caracteres | | `400` | `MISSING_FIELD` | `current_password` vazio | | `401` | `INVALID_CURRENT_PASSWORD` | Senha atual incorreta | --- ### POST /v1/auth/me/email/start Solicita troca de email. Exige o **novo email** + a **senha atual**. Envia código de 6 dígitos para o **novo email** + notificação de aviso para o **email antigo**. Token persiste em Redis por 30min; uma segunda chamada antes da confirmação **sobrescreve** a primeira (last-write-wins). **Auth:** JWT em `Authorization: Bearer ` **Request:** ```json { "new_email": "renata.new@example.com", "current_password": "MinhaSenhaAtual" } ``` **Resposta 202:** ```json { "message": "verification code sent to the new email", "expires_in": 1800 } ``` **Erros:** | Status | Código | Quando | |---|---|---| | `400` | `BAD_REQUEST` | Email inválido ou igual ao email atual | | `400` | `MISSING_FIELD` | `new_email` ou `current_password` vazio | | `401` | `INVALID_CURRENT_PASSWORD` | Senha atual incorreta | | `409` | `EMAIL_ALREADY_TAKEN` | `new_email` já está em uso por outra conta | --- ### POST /v1/auth/me/email/confirm Confirma o código de 6 dígitos recebido no novo email. Em sucesso, troca `users.email` no DB e **revoga todos os refresh tokens**. Cliente deve descartar a sessão e redirecionar para `/login`. **Auth:** JWT em `Authorization: Bearer ` **Request:** ```json { "code": "123456" } ``` **Resposta 200:** ```json { "message": "email updated — please log in again", "requires_relogin": true } ``` **Erros:** | Status | Código | Quando | |---|---|---| | `400` | `EMAIL_CHANGE_CODE_INVALID` | Código não bate. Token contínua válido até 5 tentativas. | | `404` | `EMAIL_CHANGE_NOT_FOUND` | Sem `start` prévio ou TTL expirou | | `429` | `EMAIL_CHANGE_LOCKED` | 5 tentativas erradas; token foi destruído. Reinicie com `start` | --- ## 4. Tokens de API ### POST /v1/tokens Cria um novo token de API. **Auth:** Owner **Request:** ```json { "label": "producao", "role": "agent" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `label` | string | sim | Nome identificador do token | | `role` | string | não | Role do token (`owner`, `admin`, `agent`). Padrão: `agent` | **Resposta 201:** ```json { "token_id": 2, "token": "bza_xxxx...xxxx", "label": "producao", "role": "agent", "last4": "xxxx", "created_at": "2026-03-07T10:00:00Z" } ``` > **Importante:** O token so e exibido uma vez. Salve-o em local seguro. --- ### GET /v1/tokens Lista todos os tokens da empresa. **Auth:** Owner **Resposta 200:** ```json [ { "id": 1, "label": "producao", "last4": "xxxx", "role": "agent", "created_at": "2026-03-07T10:00:00Z" } ] ``` --- ### DELETE /v1/tokens/{tokenId} Revoga um token (não pode ser desfeito). **Auth:** Owner **Resposta 200:** ```json { "message": "token revoked", "token_id": 2 } ``` --- ## 5. Usuários ### POST /v1/users Cria um novo usuário na empresa. **Auth:** Owner **Request:** ```json { "email": "agente@empresa.com", "name": "Maria Souza", "password": "Senha1234", "role": "agent" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `email` | string | sim | Email único | | `name` | string | sim | Nome do usuário | | `password` | string | sim | Senha (mínimo 8 caracteres) | | `role` | string | não | `owner`, `admin` ou `agent`. Padrão: `agent` | **Resposta 201:** ```json { "user_id": 3, "email": "agente@empresa.com", "role": "agent" } ``` --- ### GET /v1/users Lista todos os usuários da empresa. **Auth:** Owner **Resposta 200:** ```json [ { "id": 1, "email": "dono@empresa.com", "name": "Joao Silva", "role": "owner" }, { "id": 3, "email": "agente@empresa.com", "name": "Maria Souza", "role": "agent" } ] ``` --- ### PATCH /v1/users/{userId}/role Altera o role de um usuário. **Auth:** Owner **Request:** ```json { "role": "admin" } ``` **Resposta 200:** ```json { "user_id": 3, "role": "admin", "message": "role updated" } ``` --- ## 6. Instâncias WhatsApp Uma instância representa uma sessão WhatsApp conectada. ### POST /v1/instances Cria uma nova instância. **Auth:** Owner, Admin **Request:** ```json { "name": "Atendimento Principal" } ``` **Resposta 201:** ```json { "id": "84c2e480-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "name": "Atendimento Principal", "status": "DISCONNECTED", "phone": "", "created_at": "2026-03-07T10:00:00Z", "updated_at": "2026-03-07T10:00:00Z" } ``` --- ### GET /v1/instances Lista todas as instâncias da empresa. **Auth:** Owner, Admin **Resposta 200:** ```json [ { "id": "84c2e480-...", "name": "Atendimento Principal", "status": "CONNECTED", "phone": "554137984905", "connected_at": "2026-03-07T10:05:00Z", "last_seen": "2026-03-07T12:00:00Z", "profile_name": "Empresa LTDA", "profile_pic_url": "https://...", "last_error": "", "ban_expiry": null, "ban_reason": "", "created_at": "2026-03-07T10:00:00Z", "updated_at": "2026-03-07T12:00:00Z", "uptime": "1h 55m 0s" } ] ``` > O campo `last_error` aparece quando a instância teve um problema (ex: conflito de número). E limpo automaticamente quando a instância conecta com sucesso. > Os campos `ban_expiry` e `ban_reason` aparecem quando a instância recebeu ban temporario do WhatsApp. --- ### GET /v1/instances/{instanceId} Retorna detalhes de uma instância. **Auth:** Owner, Admin **Resposta 200:** Objeto de instância (mesmo formato da listagem). **Exemplo com erro de conflito:** ```json { "id": "aaa-bbb-...", "name": "Duplicada", "status": "DISCONNECTED", "last_error": "phone 554192464230 already connected on instance 84c2e480-...", "created_at": "2026-03-08T01:00:00Z", "updated_at": "2026-03-08T01:00:05Z" } ``` **Erros:** - `404` - Instância não encontrada --- ### GET /v1/instances/{instanceId}/stealth Retorna os toggles tenant-controllables de stealth da instância. **Auth:** Owner, Admin **Resposta 200:** ```json { "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53", "humanize_default": { "typing_ms": 500 }, "hygiene_skip": false, "require_inbound_first": true } ``` **Notas:** - `require_inbound_first=true` faz o worker bloquear `Send*` para chats sem inbound previo na janela de 30 dias, retornando `ERR_CHAT_NOT_INITIATED` antes de qualquer warmup gate. - O cache de elegibilidade do gate fica em Redis por 5 minutos por `instance + chat`; o fallback consulta a tenant DB. **Erros:** - `404` - Instância não encontrada --- ### PATCH /v1/instances/{instanceId}/stealth Atualiza os toggles tenant-controllables de stealth da instância. **Auth:** Owner, Admin **Request (campos opcionais, envie pelo menos um):** ```json { "humanize_default": { "typing_ms": 500 }, "hygiene_skip": false, "require_inbound_first": true } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `humanize_default` | object/null | não | Default de humanizacao persistido em `tenant_instances.humanize_default` | | `hygiene_skip` | bool | não | Opt-out da higiene automática | | `require_inbound_first` | bool | não | Ativa/desativa o gate inbound-first por instância | **Resposta 200:** Mesmo payload do `GET /v1/instances/{instanceId}/stealth`. **Erros:** - `400` - JSON inválido ou corpo sem nenhum campo atualizavel - `404` - Instância não encontrada --- ### GET /v1/stealth/wizard-defaults Retorna o perfil padrão do onboarding Stealth Mode salvo para a empresa autenticada. **Auth:** Owner, Admin **Resposta 200:** ```json { "profile": "safe", "configured": false } ``` **Notas:** - `configured=false` significa que a empresa ainda não salvou um perfil explicito; o frontend deve tratar `safe` como recomendacao padrão. - Valores válidos de `profile`: `test`, `safe`, `max`. **Erros:** - `403` - Contexto tenant ausente (ex.: sessão superadmin) --- ### PATCH /v1/stealth/wizard-defaults Salva o perfil padrão do onboarding Stealth Mode para a empresa autenticada. **Auth:** Owner, Admin **Request:** ```json { "profile": "max" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `profile` | string | sim | Um de `test`, `safe`, `max` | **Resposta 200:** ```json { "profile": "max", "configured": true } ``` **Erros:** - `400` - JSON inválido ou `profile` fora de `test|safe|max` - `403` - Contexto tenant ausente (ex.: sessão superadmin) --- ### GET /v1/instances/{instanceId}/audit Retorna a timeline de auditoria Stealth Mode da instância. **Auth:** Owner, Admin **Query Parameters:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `type` | string | Filtra por `event_type` exato (`fingerprint.rotated`, `warmup.phase_changed`, etc.) | | `from` | string | Início do intervalo em RFC3339 ou `YYYY-MM-DD` | | `to` | string | Fim do intervalo em RFC3339 ou `YYYY-MM-DD` | | `limit` | int | Máximo de linhas retornadas (default `100`, max `500`) | **Resposta 200:** ```json { "events": [ { "id": 42, "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53", "event_type": "fingerprint.rotated", "event_data": { "profile_id": "fp_01j5n3m2m9j6n6k0a4f3s8r2q1" }, "occurred_at": "2026-04-21T13:00:00Z", "created_at": "2026-04-21T13:00:00Z" } ] } ``` **Erros:** - `400` - `limit` inválido ou datas fora de RFC3339 / `YYYY-MM-DD` - `404` - Instância não encontrada --- ### GET /v1/instances/{instanceId}/audit/export?format=csv|pdf Gera um snapshot point-in-time da timeline Stealth Mode da instância e devolve o arquivo como attachment. **Auth:** Owner, Admin **Query Parameters:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `format` | string | Obrigatório. `csv` ou `pdf` | | `type` | string | Mesmo filtro do `GET /audit` | | `from` | string | Mesmo filtro do `GET /audit` | | `to` | string | Mesmo filtro do `GET /audit` | **Resposta 200:** - `format=csv` -> `Content-Type: text/csv; charset=utf-8` - `format=pdf` -> `Content-Type: application/pdf` - Ambos retornam `Content-Disposition: attachment; filename="instance-audit--."` **Notas:** - O export usa placeholder neutro de branding (`Instance Audit Export`) para não expor a marca BiaZap em artefatos compartilhados pelo tenant. - O endpoint e o caminho recomendado para retenção própria do cliente além da janela online de `STEALTH_AUDIT_RETENTION`. **Erros:** - `400` - `format` fora de `csv|pdf` ou datas inválidas - `404` - Instância não encontrada --- ### GET /v1/instances/{instanceId}/stealth/verify Executa uma verificação fresca do transporte uTLS da instância no worker. O worker abre um novo request HTTPS pela mesma trilha de sessão (`TCP -> CONNECT -> uTLS -> HTTP/1.1`) contra o servidor interno de verificação, captura o `ClientHello`, calcula o hash JA3 e devolve o resultado em cache por 15 minutos por instância. **Auth:** Owner, Admin **Resposta 200:** ```json { "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53", "profile_id": "fp_01j5n3m2m9j6n6k0a4f3s8r2q1", "utls_spec": "HelloChrome_120", "ja3_hash": "473f0e7c0b6a0f7b049072f4e683068b", "verified_at": "2026-04-21T12:00:00Z", "cached": false } ``` **Notas:** - `cached=true` indica que o worker serviu o último resultado válido (TTL de 15 minutos) sem repetir o handshake. - Quando `STEALTH_UTLS_ENABLED=false`, o endpoint responde erro porque não existe transporte uTLS ativo para verificar. - Quando a instância não tem proxy dedicado e `STEALTH_FAIL_CLOSED=true`, o worker recusa a verificação com erro de binding ausente. **Erros:** - `404` - Instância não encontrada - `502` - Falha ao consultar o worker/servidor de verificação --- ### PATCH /v1/instances/{instanceId} Renomeia a instância (label interno BiaZap). O nome não afeta o perfil WhatsApp; ele e usado apenas no Console e nas integrações que listam instâncias. **Auth:** Owner, Admin **Request:** ```json { "name": "Atendimento SP" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `name` | string | sim | 3-80 caracteres após trim. Único por empresa. | **Resposta 200:** Objeto de instância com o novo `name`. **Erros:** - `400 BAD_REQUEST` - `name` fora do tamanho permitido (3-80) - `404 INSTANCE_NOT_FOUND` - instância não pertence a empresa autenticada - `409 DUPLICATE_INSTANCE_NAME` - já existe outra instância com esse nome na mesma empresa **Notas:** - Renomear sempre o mesmo valor (sem alteração real) e idempotente: retorna `200` com a instância atual sem tocar o DB. - A operação não toca whatsmeow nem reinicia a sessão — e uma alteração puramente de label no DB do tenant. --- ### GET /v1/instances/{instanceId}/settings Retorna configurações operacionais da instância. **Auth:** Owner, Admin **Resposta 200:** ```json { "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53", "humanize_enabled": true, "media_jitter_enabled": true, "anti_spam_guard_enabled": true, "anti_spam_temperature_enabled": true, "max_outbound_without_inbound": 2 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `instance_id` | string | ID da instância | | `humanize_enabled` | bool | Controla a humanizacao automática de mensagens de texto enviadas pela API. Default `true`. | | `media_jitter_enabled` | bool | Controla jitter de bytes por envio de mídia outbound antes do upload para a Meta. Default `true`. | | `anti_spam_guard_enabled` | bool | Ativa o guard anti-spam (conteúdo duplicado em 24h + limite consecutivo sem resposta). Default `true`. | | `anti_spam_temperature_enabled` | bool | Toggle da curva de temperatura por contato. Quando `true` (default), substitui o limite fixo `max_outbound_without_inbound` por uma curva derivada do score por (instance, remote_jid): `cold` 2 / `warm` 3 / `engaged` 4 / `hot` 5 / `very_hot` 7. | | `max_outbound_without_inbound` | int | Limite fixo de envios consecutivos sem resposta (1-10, default `2`). Aplicado apenas quando `anti_spam_temperature_enabled=false`. Ignorado quando a temperatura está ativa. | **Erros:** - `404 INSTANCE_NOT_FOUND` - instância não pertence a empresa autenticada --- ### PATCH /v1/instances/{instanceId}/settings Atualiza configurações operacionais da instância. Aceita PATCH parcial — qualquer combinação dos campos abaixo. **Auth:** Owner, Admin **Request:** ```json { "humanize_enabled": false, "media_jitter_enabled": true, "anti_spam_guard_enabled": true, "anti_spam_temperature_enabled": true, "max_outbound_without_inbound": 3 } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `humanize_enabled` | bool | não | Ativa/desativa `composing` + delay aleatorio antes de `SendText` | | `media_jitter_enabled` | bool | não | Ativa/desativa jitter de bytes por envio de mídia outbound. Quando `false`, o worker envia os bytes canônicos lidos do R2. | | `anti_spam_guard_enabled` | bool | não | Ativa/desativa o guard anti-spam (regras abaixo). Quando `false`, todas as regras são puladas. | | `anti_spam_temperature_enabled` | bool | não | Quando `true` (default), a curva de temperatura por contato substitui `max_outbound_without_inbound` (curva: cold 2 / warm 3 / engaged 4 / hot 5 / very_hot 7). Quando `false`, vale o limite fixo. O hash-dedup de 24h continua ativo em ambos os casos. | | `max_outbound_without_inbound` | int | não | 1-10. Limite fixo aplicado apenas quando `anti_spam_temperature_enabled=false`. Ignorado quando a temperatura está ativa. | Ao menos um dos campos e obrigatório. Os campos omitidos preservam o valor atual. **Resposta 200:** Mesmo payload do `GET /v1/instances/{instanceId}/settings` após a atualização. **Erros:** - `400 MISSING_FIELD` - corpo sem nenhum campo aceito (`humanize_enabled`, `media_jitter_enabled`, `anti_spam_guard_enabled`, `anti_spam_temperature_enabled`, `max_outbound_without_inbound`) - `400 BAD_REQUEST` - `max_outbound_without_inbound` fora do range 1-10 - `404 INSTANCE_NOT_FOUND` - instância não pertence a empresa autenticada --- ### Anti-spam guard Quando `anti_spam_guard_enabled=true` (default), todo envio outbound passa por duas regras antes de ser enfileirado: 1. **Conteúdo duplicado** (rolling 24h, escopo `(instance_id, remote_jid)`): - Texto: hash do `text` (trim). - Mídia: hash de `media_url|media_id + caption + file_name`. - Localização: hash de `(lat, lng, name, address)`. - Contato: hash de `(contact_name, contact_phone)`. - Poll: hash de `question + options`. - Template: hash de `body + footer`. - Reaction/Forward/Delete/Edit ficam fora dessa regra (operam em mensagem existente). - **Reset por inbound:** quando o contato responde (qualquer `*events.Message` com `IsFromMe=false`), TODOS os hashes acumulados nas últimas 24h pra esse `(instance_id, remote_jid)` são descartados. Permite que respostas curtas naturais (`"sim"`, `"ok"`, `"obrigado"`) se repitam ao longo do dia sem disparar bloqueio. O padrão anti-blast original (mesmo template enviado N vezes em sequência sem nenhuma resposta) continua sendo bloqueado normalmente porque, por definição, não há inbound nele. 2. **Reciprocidade — limite consecutivo sem resposta inbound**. A política depende de `anti_spam_temperature_enabled`: - **Quando `anti_spam_temperature_enabled=true` (default)** → a curva de temperatura por contato substitui o limite fixo. `max_outbound_without_inbound` é **ignorado**. O limite efetivo passa a ser derivado do score por `(instance_id, remote_jid)`: `cold` 2 · `warm` 3 · `engaged` 4 · `hot` 5 · `very_hot` 7. Bloqueio retorna `409 CONFLICT` com `error_code: "BLOCKED_TEMPERATURE_LIMIT"` (payload inclui `remote_jid`, `score`, `tier`, `max_consecutive`, `outbound_consecutive`). - **Quando `anti_spam_temperature_enabled=false`** → vale o limite fixo `max_outbound_without_inbound` (1-10, default 2), rolling window 7d. Cada inbound `*events.Message` zera o contador via worker. Bloqueio retorna `409 CONFLICT` com `error_code: "BLOCKED_NO_RECIPROCITY"` (payload inclui `remote_jid`, `count`, `threshold`). Em ambos os casos, o contador subjacente é zerado a cada inbound do contato. **Resumo da precedência:** | `anti_spam_guard_enabled` | `anti_spam_temperature_enabled` | Conteúdo duplicado | Reciprocidade — limite consecutivo | |---|---|---|---| | `false` | qualquer | desativado | desativado | | `true` | `false` | ativo (hash 24h) | ativo — limite fixo `max_outbound_without_inbound` | | `true` | `true` | ativo (hash 24h) | ativo — curva por score (temperatura), limite fixo ignorado | **Bypass:** envie o header `X-Force-Send: true` na requisição. O guard registra audit log com `audit_action=anti_spam.force_send` por regra ignorada. Tenant admin/owner deve usar com critério. --- ### POST /v1/instances/{instanceId}/connect Conecta a instância ao WhatsApp. Gera QR code para parear. **Auth:** Owner, Admin **Request (opcional):** ```json { "qr_retries": 3 } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `qr_retries` | int | não | Tentativas de QR (1-3). Padrão: 1 | **Resposta 200:** Objeto de instância com QR code. --- ### POST /v1/instances/{instanceId}/disconnect Desconecta a instância do WhatsApp (mantem a sessão para reconexao). **Auth:** Owner, Admin **Resposta 200:** Objeto de instância atualizado. --- ### POST /v1/instances/{instanceId}/restart Reinicia a conexão da instância. **Auth:** Owner, Admin **Resposta 200:** Objeto de instância atualizado. --- ### POST /v1/instances/{instanceId}/logout Faz logout do WhatsApp e apaga a sessão. A instância precisara parear novamente. **Auth:** Owner, Admin **Resposta 200:** Objeto de instância atualizado. --- ### POST /v1/instances/{instanceId}/history/import Solicita importação manual de histórico antigo. A chamada e assincrona: o endpoint apenas pede ao WhatsApp uma página de histórico por conversa conhecida; as mensagens retornam depois em eventos `history.sync` e são gravadas na tabela `messages`, ficando disponíveis em `GET /v1/instances/{instanceId}/chats` e `GET /v1/instances/{instanceId}/chats/{chatId}/messages`. **Auth:** Owner, Admin **Request (opcional):** ```json { "chat_jid": "554137984905@s.whatsapp.net", "limit": 50 } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `chat_jid` | string | não | Quando informado, solicita histórico apenas desse chat. Vazio = uma página para cada chat com mensagem ancora já salva | | `limit` | int | não | Mensagens por chat. Padrão `50`, máximo `100` | **Resposta 200:** ```json { "instance_id": "84c2e480-...", "status": "requested", "limit": 50, "requested_chats": 3, "requested_messages": 150 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `status` | string | `requested` quando ao menos um pedido foi enviado ao WhatsApp; `skipped` quando não havia ancora de mensagem | | `requested_chats` | int | Quantos chats receberam pedido `ON_DEMAND` | | `requested_messages` | int | Máximo teorico solicitado (`requested_chats * limit`) | | `skipped_chats` | int | Chats que tinham ancora mas o pedido ao WhatsApp falhou | | `message` | string | Motivo quando `status=skipped` | **Notas:** - A importação manual depende de uma mensagem ancora já existente no histórico local. Para conexões novas, o `history.sync` automático do WhatsApp agora também e persistido e normalmente cria essas ancoras. - Mensagens históricas são salvas com `source="phone"` para inbound e `source="external"` para outbound vindo do celular/WhatsApp Web. Elas não disparam `message.received`/`message.sent` como se fossem tráfego novo ao vivo. - O retorno do WhatsApp contínua sendo resumido por `history.sync`; leia as mensagens importadas pelos endpoints de chat/mensagens. **Erros:** - `400` - JSON inválido - `404` - Instância não encontrada - `400` - Instância não conectada ou falha ao solicitar histórico ao worker --- ### DELETE /v1/instances/{instanceId} Remove a instância permanentemente. **Auth:** Owner, Admin **Resposta 204:** Sem corpo. --- ### GET /v1/instances/{instanceId}/qr Retorna o QR code atual para parear a instância. **Auth:** Owner, Admin **Resposta 200:** ```json { "qr_code": "2@abc123...", "qr_base64": "iVBORw0KGgo...", "instance_id": "84c2e480-..." } ``` > O campo `qr_base64` contem a imagem PNG do QR em base64. Se a instância não tiver QR pendente, retorna `400`. --- ### GET /v1/instances/{instanceId}/qr/stream Stream SSE de QR codes. O servidor envia QR codes conforme são gerados pelo WhatsApp (~20s cada, até 6 por sessão). Quando o usuário escaneia, envia `success`. Se todos os QRs expiram, envia `timeout`. **Auth:** Owner, Admin **Headers recomendados para proxies reversos:** - `X-Accel-Buffering: no` (já incluído na resposta) - Desabilitar compressao (`no-gzip`) para evitar buffering do SSE **Resposta:** Server-Sent Events ``` event: connected data: {"instance_id": "84c2e480-..."} event: code data: {"event": "code", "qr": "2@abc123...", "qr_base64": "iVBORw0KGgo...", "mime_type": "image/png"} event: success data: {"event": "success"} event: timeout data: {"event": "timeout"} event: error data: {"event": "error", "message": "descricao do erro"} ``` | Evento | Descricao | |--------|-----------| | `connected` | Stream iniciado (enviado imediatamente) | | `code` | Novo QR code disponível. `qr` = raw string, `qr_base64` = imagem PNG base64 | | `success` | Usuário escaneou o QR, instância pareada | | `timeout` | Todos os QR codes expiraram sem leitura | | `error` | Erro ao gerar QR | > **Nota:** O campo `qr_base64` contem a imagem PNG do QR pronta para exibir: ``. O campo `qr` contem a string raw para gerar o QR localmente. > **Proxy reverso:** Se usar Apache/Nginx como proxy, desabilite compressao e buffering para endpoints SSE (`/qr/stream` e `/events`). Sem isso, o browser recebe `ERR_INCOMPLETE_CHUNKED_ENCODING`. --- ### POST /v1/instances/{instanceId}/pairing-code Gera um código de pareamento (alternativa ao QR code, para parear pelo telefone). **Auth:** Owner, Admin **Request:** ```json { "phone": "554137984905" } ``` **Resposta 200:** ```json { "instance_id": "84c2e480-...", "pairing_code": "SG3M-82AE" } ``` > **Como usar:** No WhatsApp do celular, va em Configurações > Aparelhos Conectados > Conectar Aparelho > Conectar com número de telefone. Digite o código recebido. --- ### 6.1 Boas Práticas — Fluxo de Conexão (QR dinâmico e Pairing Code) Está seção descreve como montar uma tela de conexão rápida, resiliente e com UX parecida com a do BiaZap Console. Os usuários que apenas fazem polling em `GET /qr` ou abrem o SSE sem uma estrategia ficam com telas lentas, QR "parado" e retry manual irritante — todos esses problemas são resolvidos com o padrão abaixo. #### Maquina de estados da instância ``` CREATED ──POST /connect──► CONNECTING ──► QR_PENDING ──(scan)──► CONNECTED │ │ ▼ ├─(timeout 6 QRs)─► TIMED_OUT DISCONNECTED │ └─(erro)──────────► DISCONNECTED ``` | Estado | Significado | Ação do cliente | |--------|-------------|-----------------| | `CREATED` | Instância criada, ainda não tentou conectar | `POST /connect` para iniciar | | `CONNECTING` | Abrindo websocket com WhatsApp, gerando primeiro QR | Abrir `/qr/stream` e esperar evento `code` | | `QR_PENDING` | Pelo menos um QR já disponível | Exibir QR e aguardar `success`/`timeout` | | `CONNECTED` | Pareado e recebendo mensagens | Fechar modal, liberar a tela | | `TIMED_OUT` | 6 QRs expiraram sem leitura (~2min total) | `POST /restart` e reabrir stream, ou pedir ao usuário para tentar de novo | | `DISCONNECTED` | Desconectada (manual ou erro) | `POST /connect` para reativar | | `BANNED` | Banida pelo WhatsApp | Não reconecte automaticamente — veja seção 16, evento `instance.banned` | O WhatsApp gera um novo QR a cada ~20 segundos e aceita até 6 QRs por sessão de pareamento (totalizando ~2 minutos). Esses QRs chegam automaticamente no `/qr/stream` via evento `code` — **não existe endpoint "próximo QR"**. Se você perder esse stream, precisa reiniciar (`POST /restart`) para reabrir a sessão. #### Fluxo recomendado (5 passos) ``` 1. POST /v1/instances/{id}/connect ◄── dispara QR generation 2. GET /v1/instances/{id}/qr (snapshot) ◄── tela nao fica branca 3. GET /v1/instances/{id}/qr/stream (SSE) ◄── recebe novos QRs + success/timeout 4. GET /v1/instances/{id}/events?events=connection.update (SSE paralelo) ◄── rede de seguranca 5. Ao receber "success" ou CONNECTED: fechar streams, mostrar sucesso ``` A combinação `/qr/stream` + `/events` em paralelo e a diferença entre "funciona na maioria das vezes" e "funciona sempre". Se um proxy reverso engolir o evento `success` do `/qr/stream`, a transicao `CONNECTING → CONNECTED` em `connection.update` ainda chega pelo `/events` e você fecha o modal corretamente. #### Contrato do `/qr/stream` O servidor envia eventos nomeados SSE conforme a [seção GET /v1/instances/{instanceId}/qr/stream](#get-v1instancesinstanceidqrstream). Resumindo: | Evento | Quando ocorre | Payload relevante | O que fazer | |--------|--------------|-------------------|-------------| | `connected` | Imediatamente ao abrir | `{ "instance_id": "..." }` | Confirmar que o stream abriu | | `code` | Novo QR disponível (0s, ~20s, ~40s...) | `{ "qr_base64": "...", "qr": "2@...", "mime_type": "image/png" }` | Renderizar `` | | `success` | Usuário escaneou o QR | `{ "event": "success" }` | Fechar modal, emitir toast de sucesso | | `timeout` | 6 QRs expiraram sem scan | `{ "event": "timeout" }` | Silent retry ou prompt de "tente de novo" | | `error` | Falha na sessão whatsmeow | `{ "event": "error" }` | Silent retry ou mostrar erro | > **Idle timeout:** se o stream ficar mais de ~45s sem nenhum evento (nem heartbeat), trate como conexão morta e reabra. Redes móveis e proxies derrubam conexões idle silenciosamente. > **Limite de subscribers:** cada instância aceita até **20 conexões SSE/WebSocket simultaneas** (somando `/qr/stream` + `/events` + clientes externos). Se você abrir streams em varias abas ou não fizer cleanup, atinge o limite e recebe `429 Too Many Requests`. Sempre use `AbortController` para cancelar. #### Padrão 1 — Cliente Browser (JS/TS) **Por que não usar `EventSource` nativo:** o construtor `EventSource(url)` **não aceita headers customizados**, logo você não consegue mandar `Authorization: Bearer `. A solução usada pelo BiaZap Console e usar `fetch()` com `ReadableStream`, o que permite headers e cancelamento via `AbortController`. ```javascript /** * Abre o stream SSE de QR code para uma instancia. * Retorna uma funcao `close()` para abortar o stream limpo. */ function openQRStream(instanceId, token, handlers) { const controller = new AbortController(); const url = `https://api.catcher.one/v1/instances/${instanceId}/qr/stream`; fetch(url, { method: 'GET', signal: controller.signal, cache: 'no-store', headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}`, }, }) .then(async (response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.body) throw new Error('stream body unavailable'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Eventos SSE sao delimitados por \n\n let boundary; while ((boundary = buffer.indexOf('\n\n')) !== -1) { const raw = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); if (!raw.trim()) continue; let eventType = 'message'; let data = ''; for (const line of raw.split('\n')) { if (line.startsWith(':')) continue; // heartbeat, ignora if (line.startsWith('event:')) eventType = line.slice(6).trim(); else if (line.startsWith('data:')) data = data ? `${data}\n${line.slice(5).trim()}` : line.slice(5).trim(); } try { handlers.onEvent?.(eventType, data ? JSON.parse(data) : {}); } catch { /* payload malformado, ignora */ } } } handlers.onClose?.(); }) .catch((err) => { if (err.name === 'AbortError') return; handlers.onError?.(err); }); return () => controller.abort(); } // Uso: const close = openQRStream(instanceId, token, { onEvent: (type, data) => { if (type === 'code') setQR(data.qr_base64); if (type === 'success') { setConnected(true); close(); } if (type === 'timeout') silentRetry(); }, onError: () => silentRetry(), }); ``` > **Snapshot antes do stream:** para evitar o flash de "Gerando QR Code..." quando já existe um QR pendente, **chame `GET /qr` antes de abrir o stream** e renderize o QR imediatamente. O stream entao atualizara o QR a cada 20s. > ```javascript > // 1. Snapshot sincrono — tela mostra QR na hora > const snap = await fetch(`${api}/instances/${id}/qr`, { headers: auth }).then(r => r.ok ? r.json() : null); > if (snap?.qr_base64) setQR(snap.qr_base64); > // 2. Em seguida o stream mantem o QR atualizado > const close = openQRStream(id, token, handlers); > ``` > **Alternativa mais simples:** se você não quiser escrever o parser manual, use a biblioteca [`@microsoft/fetch-event-source`](https://www.npmjs.com/package/@microsoft/fetch-event-source). Ela expoe uma API `fetchEventSource()` identica ao `EventSource` nativo mas com suporte a headers, retry automático e `AbortController`. #### Padrão 2 — Cliente Backend (Node/Go/Python) No backend não ha restrição de headers, entao qualquer cliente SSE funciona. Curl e um bom teste rápido: ```bash curl -N \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: text/event-stream" \ "https://api.catcher.one/v1/instances/$INSTANCE_ID/qr/stream" ``` Exemplo em Go com `bufio.Scanner`: ```go req, _ := http.NewRequest("GET", baseURL+"/v1/instances/"+instanceID+"/qr/stream", nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { /* ... */ } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) var eventType, data string for scanner.Scan() { line := scanner.Text() switch { case strings.HasPrefix(line, "event:"): eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) case strings.HasPrefix(line, "data:"): data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) case line == "": // linha em branco = fim do evento handleQREvent(eventType, data) eventType, data = "", "" } } ``` #### Dupla subscricao — `/qr/stream` + `/events` em paralelo Como rede de seguranca, assine `connection.update` em paralelo pelo endpoint de eventos da instância. Isso garante que você fecha o modal mesmo se o `success` do `/qr/stream` for perdido por qualquer motivo (buffering de proxy, timing de fechamento do stream, etc.). ```javascript // Stream de QR (atualiza imagem) const closeQR = openQRStream(instanceId, token, { onEvent: (type, data) => { if (type === 'code') setQR(data.qr_base64); if (type === 'success') markConnected(); if (type === 'timeout') silentRetry(); }, }); // Stream de eventos da instancia (rede de seguranca) const closeEvents = openEventStream(instanceId, token, ['connection.update'], (event) => { if (event.type === 'connection.update' && event.data?.status === 'CONNECTED') { markConnected(); } }); function markConnected() { closeQR(); closeEvents(); showSuccess('Instancia conectada!'); } ``` Se você já tem uma conexão `/events` global no seu app (por exemplo, ouvindo `message.received` o tempo todo), apenas **adicione um listener** para `connection.update` durante a tela de conexão — **não abra uma segunda conexão** para a mesma instância, você gastara uma das 20 slots disponíveis. #### Auto-retry silencioso (UX sem friction) Usuários abandonam a tela de conexão quando veem "QR expirou, clique para tentar novamente". A tela do Console aplica **retry invisível**: ao receber `timeout` ou `error`, dispara `POST /restart` e reabre o `/qr/stream`, mantendo o QR atual visível até o próximo evento `code`. ```javascript const MAX_RETRIES = 10; let retries = 0; async function silentRetry() { if (retries >= MAX_RETRIES) { showTimeoutUI(); return; } retries += 1; // pequeno debounce antes de reiniciar await new Promise(r => setTimeout(r, 1500)); // reinicia a sessao do lado do servidor await fetch(`${api}/instances/${id}/restart`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }); // reabre o stream — o QR atual continua visivel ate chegar um novo `code` closeQR = openQRStream(id, token, handlers); } ``` **Não apague o QR atual durante o retry** — deixe o último QR válido na tela. Se o novo `code` chegar antes do usuário tentar escanear o anterior, o anterior ainda funciona por alguns segundos. Trocar a imagem so quando chegar o evento `code` evita flicker. #### Pairing Code como alternativa ao QR O pairing code e util quando o usuário não tem uma camera funcionando (desktop sem webcam, usuário com dificuldade visual etc.). Tres regras importantes: 1. **Requer `POST /connect` ANTES**. A chamada `POST /pairing-code` so funciona se a instância estiver em `CONNECTING` ou `QR_PENDING` — caso contrario retorna `400 instance not connected, call connect first`. Na prática, execute o mesmo fluxo do QR e apenas troque a interface. 2. **Código expira em ~60 segundos**. Use um timer no cliente, mostre a contagem regressiva e permita gerar um novo código. 3. **Sucesso chega pelo mesmo `/events?events=connection.update`** — não ha stream dedicado para pairing. Mantenha a subscricao de `connection.update` ativa e feche a UI quando vier `status: CONNECTED`. ```javascript // 1. Garante que a sessao esta CONNECTING await fetch(`${api}/instances/${id}/connect`, { method: 'POST', headers: auth }); // 2. Gera o codigo (phone inclui DDI, ex: "554137984905") const res = await fetch(`${api}/instances/${id}/pairing-code`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth }, body: JSON.stringify({ phone }), }); const { pairing_code } = await res.json(); // ex: "SG3M82AE" setCode(formatPairingCode(pairing_code)); // "SG3M-82AE" // 3. Timer de expiracao setTimeout(() => { setCode(null); toast.info('Codigo expirado, gere um novo.'); }, 60_000); // 4. Sucesso vem via connection.update // (ja esta assinado pelo stream de /events da rede de seguranca) ``` #### Checklist — como NÃO fazer | Anti-padrão | Consequência | O certo | |-------------|--------------|---------| | Usar `new EventSource(url)` sem token na URL | 401 silencioso, stream nunca abre | `fetch()` + `ReadableStream` ou `@microsoft/fetch-event-source` | | Token via query string (`?token=`) | Rejeitado (401); query auth foi removida | Header `Authorization: Bearer ` | | Polling de `GET /qr` a cada 1s | Spam de 60 req/min, QR desatualizado | SSE `/qr/stream` (push-based, atualiza sozinho) | | Não chamar `POST /connect` antes | `GET /qr` devolve `400`, stream so envia `connected` e fica parado | Sempre `POST /connect` primeiro | | Não chamar `GET /qr` como snapshot | Tela branca por 2-5s na primeira abertura | Snapshot sincrono antes do stream | | Abrir stream, não cancelar no unmount | Vaza subscribers, esgota o limite de 20 e vira 429 | `AbortController.abort()` ao fechar | | Abrir 2+ streams para a mesma instância | Consome slots a toa | Reaproveite um único stream via listener pub/sub | | Apagar o QR ao receber `timeout` | Flicker visual, UX quebrada | Manter o QR visível até o próximo `code` | | Não renovar o access token antes de reabrir | 401 durante reconexao longa | `POST /auth/refresh` antes de tentar reconectar | | Depender apenas do `success` do `/qr/stream` | Perde o sucesso se o proxy buffer engolir o evento | Dupla subscricao com `/events?events=connection.update` | | Reconectar sem backoff | Loop de erros em caso de falha persistente | Backoff exponencial (2s → 4s → 8s…), cap em 30s | | Ignorar o evento `instance.banned` | Retry infinito em instância banida | Parar reconexao ao receber `banned` — veja seção 16 | #### Limites operacionais | Limite | Valor | Observação | |--------|-------|------------| | QRs por sessão de pareamento | 6 | ~20s cada, ~2min total antes de `timeout` | | Validade do pairing code | ~60s | Gere novo após expirar | | Subscribers por instância (SSE+WS somados) | 20 | Reutilize conexões, sempre cancele no unmount | | Buffer de replay por instância | 100 eventos | Use `Last-Event-ID` ou `?last_event_id=` para retomar após queda curta | | Heartbeat do SSE | ~15s | Comentário `: heartbeat` — ignore no parser | | Idle timeout recomendado do cliente | 45s | Se nada chegar nesse tempo, reconecte | | Retry silencioso recomendado | até 10 vezes | Depois, mostrar prompt manual | #### Referência rápida dos endpoints | Passo | Endpoint | Método | Finalidade | |-------|----------|--------|-----------| | 1. Iniciar | `/v1/instances/{id}/connect` | `POST` | Dispara sessão whatsmeow e geração de QR | | 2. Snapshot | `/v1/instances/{id}/qr` | `GET` | Último QR pendente (tela imediata) | | 3. Stream | `/v1/instances/{id}/qr/stream` | `GET` (SSE) | Novos QRs + `success`/`timeout` | | 4. Rede de seguranca | `/v1/instances/{id}/events?events=connection.update` | `GET` (SSE) | Status `CONNECTED` autoritativo | | 5a. Retry | `/v1/instances/{id}/restart` | `POST` | Reabrir sessão após timeout/erro | | 5b. Pairing | `/v1/instances/{id}/pairing-code` | `POST` | Alternativa ao QR (exige passo 1) | Use está receita como ponto de partida e adapte ao seu stack. A implementação completa de referência está em `frontend/src/views/instances/components/ConnectionModal.vue` no próprio repositorio do BiaZap Console. --- ## 7. Mensagens Todas as operações assincronas desta seção (`send`, `forward`, `delete`, `edit`) são processadas em fila. A API retorna `202 Accepted` com o `message_id` do BiaZap (UUID pre-gerado na aceitacao da request), `idempotency_key` e `task_id` para rastreamento e deduplicacao segura de retries do cliente. ### Header obrigatório: `Idempotency-Key` Todos os endpoints assincronos de mensagens exigem o header HTTP `Idempotency-Key`. - Use um valor único por operação lógica. - Repetir a mesma combinação `instanceId` + tipo de operação + `Idempotency-Key` **não** cria uma nova task. - O retry recebe o estado atual do mesmo request (`queued`, `sent` ou `failed`) com o mesmo `message_id` e o mesmo `task_id`. - Clientes browser podem enviar esse header diretamente: ele faz parte dos headers permitidos no preflight CORS junto com `Authorization`, `Content-Type`, `X-API-Key` e `X-CSRF-Token`. ### Formato de destinatário O campo `to` aceita: - Número de telefone: `554137984905` (será convertido para JID automaticamente) - JID individual: `554137984905@s.whatsapp.net` - JID de grupo: `120363012345678901@g.us` ### Resposta padrão (202) Todas as operações que criam uma nova mensagem retornam: ```json { "status": "queued", "message_id": "550e8400-e29b-41d4-a716-446655440000", "task_id": "asynq:task:xxxx-xxxx", "idempotency_key": "msg-2026-03-21-0001" } ``` - **`message_id`** — UUID do BiaZap, **pre-gerado no momento da aceitacao**. E o mesmo valor que aparece como elemento de `message_ids` nos webhooks `message.sent`, `message.delivered`, `message.read`, `message.played`, `message.deleted`, `message.edited`. **Use este campo para correlacionar a resposta sincrona com os eventos assincronos** (no consumer, `message_ids[0]` = `message_id` da resposta 202). - **`idempotency_key`** — eco do header enviado pelo cliente. Também aparece no webhook `message.sent` (campo `idempotency_key`) quando a mensagem foi originada via API. - **`task_id`** — identificador interno do Asynq. Util para debug/NOC; não deve ser usado como chave de correlação primaria (use `message_id` ou `idempotency_key`). Operações que **não criam** um novo `Message` (`delete`, `edit`) retornam apenas `status`, `task_id` e `idempotency_key`; o cliente já possui o `message_id` alvo da operação. > Repetir a mesma chave idempotente devolve o mesmo `message_id`, `task_id` e o estado atual (`queued`, `sent` ou `failed`) sem enfileirar duplicidade. Este e o caminho oficial para um cliente recuperar o `message_id` caso tenha perdido a resposta original ou precise verificar o estado antes do primeiro webhook. ### Confirmando entrega de verdade (o `202` não é entrega) O `202` significa apenas **"aceito na fila"**. NÃO significa que a mensagem saiu, chegou, ou renderizou no destinatário. Para confirmar de verdade, acompanhe a escada de eventos (webhook/SSE/WS) correlacionando por `message_id`: | Evento | Prova | NÃO prova | |---|---|---| | `202 queued` (resposta HTTP) | a task entrou na fila | que saiu / chegou | | `message.sent` | o worker despachou pro WhatsApp; **a Meta aceitou o proto** | que chegou no aparelho | | `message.delivered` | chegou no aparelho do destinatário | **que renderizou / tocou** | | `message.read` | o destinatário **abriu** e o chat **renderizou** (texto, imagem, documento, sticker, localização, contato, link-preview) | — | | `message.played` | o destinatário **tocou** a mídia (áudio/PTT, vídeo, PTV, view-once) | — | O salto crítico é **`delivered → read`/`played`**: mídia pode entregar e **falhar em renderizar/tocar** no cliente (proto incompleto, container errado). Se um envio chega em `delivered` mas nunca dá `read`/`played`, trate como **não consumido** — não como entregue. Regra prática: **"sem `read`/`played`, não chegou de verdade."** **Pré-condição importante:** o `read`/`played` só dispara se o destinatário tiver **recibos de leitura ligados** E efetivamente **abrir/tocar** a mensagem. Se o destinatário não produz recibo, o piso de verificação é `message.delivered` (chegou no aparelho) + inspecionar o `message.received` do lado dele e conferir que o conteúdo bate (`==`) com o enviado: mesma `caption`/`content`, mesmo `message_type`, `media_id` presente, `phone` correto. Isso prova que chegou **correto**, mesmo sem o sinal de leitura. > **Status / Stories** têm semântica de recepção diferente: o evento `status.viewed` (alguém viu MEU status) só vem de contatos no **público** do status (contatos salvos filtrados pela privacidade de status), não de qualquer número com quem você troca DM. Ver [Status / Stories](#status--stories-133). ### Campo `quoted_id` (reply) Todos os endpoints de envio que listam `quoted_id` aceitam: - **UUID BiaZap** (formato `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`): e o mesmo UUID retornado em `message_ids` nos webhooks (`message.received`, `message.sent`, etc). A API resolve internamente para o ID hexadecimal do WhatsApp antes de enfileirar. - **ID hexadecimal do WhatsApp** (ex.: `3EB0ABC123...`): passa direto, sem lookup. Se o `quoted_id` for um UUID e não existir no banco (mensagem removida, pertence a outra instância, ou nunca foi recebida por está instância), a API responde `404 Not Found` com `error_code=MESSAGE_NOT_FOUND`. Não ha enfileiramento parcial: a mensagem so e enviada com reply válido ou não e enviada. > Isto vale para `SendText`, `SendImage`, `SendVideo`, `SendAudio`, `SendDocument`, `SendSticker`, `SendLocation`, `SendContact` e para os equivalentes de envio agendado via `POST /v1/instances/{instanceId}/messages/scheduled`. --- ### Modo passivo (`409 PASSIVE_MODE_ENABLED`) Quando a instância está configurada em modo passivo (`passive_mode_enabled=true` em `GET/PATCH /v1/instances/{id}/settings`), **todos os endpoints de envio** (`text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `reaction`, `poll`, `template`, `forward`, `delete`, `edit`) são bloqueados antes do enfileiramento: ```json { "error_code": "PASSIVE_MODE_ENABLED", "message": "instance is in passive mode — set passive_mode_enabled=false in /v1/instances/{id}/settings or pass X-Force-Send: true to override (audit-logged)", "trace_id": "..." } ``` - HTTP `409 Conflict`. - Bypass: header `X-Force-Send: true` (registra audit log `audit_action=passive_mode.force_send`). - Modo passivo falha fechado: um erro transitorio no banco do tenant retorna `503`/`500` em vez de liberar o envio (proposito: não vazar outbound de uma instância sob hold legal/monitoramento). ### Guard anti-spam (`409 BLOCKED_*`) Endpoints de envio passam por um guard anti-spam antes de enfileirar. Qualquer regra que dispare devolve `409 Conflict`: | `error_code` | Regra | Campos extras no payload | |---|---|---| | `BLOCKED_DUPLICATE_CONTENT` | Conteúdo identico já enviado nas últimas 24h | `remote_jid`, `content_hash`, `first_sent_at` | | `BLOCKED_NO_RECIPROCITY` | Envios consecutivos sem nenhum inbound do contato — limite fixo | `remote_jid`, `count`, `threshold` | | `BLOCKED_TEMPERATURE_LIMIT` | `max_consecutive` da curva de temperatura do contato excedido | `remote_jid`, `score`, `tier`, `max_consecutive`, `outbound_consecutive` | - A regra de conteúdo duplicado é controlada por `anti_spam_guard_enabled` (default `true`). - O limite consecutivo sem resposta é controlado pela combinação `anti_spam_guard_enabled` + `anti_spam_temperature_enabled`. Quando ambas estão ativas (default), a curva de temperatura por contato substitui o limite fixo — apenas `BLOCKED_TEMPERATURE_LIMIT` pode disparar. Quando a temperatura está desativada, apenas `BLOCKED_NO_RECIPROCITY` pode disparar. As duas nunca rodam ao mesmo tempo. - `delete`, `edit`, `reaction` e `forward` são isentos da regra de conteúdo duplicado (não ha shape de conteúdo comparavel), mas continuam contando para o limite consecutivo. - Bypass: header `X-Force-Send: true` (registra audit log `audit_action=anti_spam.force_send`). --- ### POST /v1/instances/{instanceId}/messages/text Envia mensagem de texto. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "text": "Ola! Como posso ajudar?", "quoted_id": "3EB0ABC123...", "mentions": ["5541988888888@s.whatsapp.net"] } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário (telefone ou JID) | | `text` | string | sim | Conteúdo da mensagem | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs de usuários mencionados | **Regras do `humanize`:** - Controlado por instância via `GET/PATCH /v1/instances/{instanceId}/settings` (`humanize_enabled`, default `true`). - Quando ativo, o processor envia `composing` imediatamente antes de `SendText` e espera `numero_de_caracteres(texto) * multiplier` segundos, com `multiplier` aleatorio entre `0.1` e `0.5`. - A aplicação ocorre apenas em mensagens de texto para chats 1:1; grupos (`@g.us`) e mensagens de mídia seguem o fluxo legado. - Se a leitura da configuração falhar, o envio segue imediatamente para não bloquear a fila. --- ### Envio de mídia: `media_url`, `media_id` ou arquivo direto Os endpoints `POST /v1/instances/{instanceId}/messages/{image|video|audio|document|sticker}` aceitam **as tres formas de origem no mesmo endpoint**: 1. `application/json` com `media_url`: o servidor **baixa a URL sincronamente** durante o request (~500ms-2s para arquivos pequenos), aplica validação SSRF/MIME/64 MB, armazena no R2/S3, gera um `media_id` interno e enfileira o envio usando esse ID. 2. `application/json` com `media_id`: reutiliza uma mídia já armazenada pelo endpoint `POST /v1/instances/{instanceId}/media` ou por mídia recebida. 3. `multipart/form-data` com campo `file`: envia o arquivo binario direto no próprio endpoint; a API armazena em R2, gera um `media_id` e enfileira o envio usando esse ID. #### Arquitetura de mídia: API resolve, worker só envia Independente da forma de origem (URL, media_id existente ou multipart), **a API sempre garante que os bytes estejam armazenados no R2 antes de retornar 202**. O worker só recebe `media_id` na fila Asynq — nunca uma URL externa para baixar. Isso elimina classe inteira de falha silenciosa em que a CDN externa bloqueia download depois do 202 e a mensagem nunca chega. Prático para você (consumidor da API): - **Falha de download é sincrona**: se a URL retorna 4xx, 5xx, timeout, ou MIME bloqueado, você recebe `502 MEDIA_FETCH_FAILED` no momento da chamada. NÃO vai pra fila, não tem retry silencioso. Retry o request com URL válida ou faca upload prévio. - **Custo de latência**: requests com `media_url` agora levam ~500ms-2s a mais que com `media_id` (tempo do download + upload R2). Para arquivos grandes (>10MB), considere fazer upload prévio via `POST /v1/instances/{id}/media` e reutilizar o `media_id` retornado. - **Cache automático**: cada `media_url` baixado vira uma linha no R2 com `source="url-cache"` e TTL de 7 dias. Reaproveitamento da mesma URL durante esse período é livre. - **Disciplina de proxy**: o download da URL passa pelo cliente HTTP padrão da API (resolver DNS-over-TLS, validador SSRF). Bright Data residential proxy é reservado APENAS para o upload `worker → Meta CDN` na hora do envio. Isso é deliberado e estrutural — ver `.claude/rules/whatsapp-proxy-discipline.md`. Campos textuais do envio (`to`, `caption`, `file_name`, `ptt`, `quoted_id`, `mentions`) podem ir no multipart como campos de formulario. `mentions` aceita valores repetidos, CSV ou JSON array. Exemplo com arquivo direto: ```bash curl -X POST "https://api.catcher.one/v1/instances/$INSTANCE/messages/image" \ -H "X-API-Key: $TOKEN" \ -H "Idempotency-Key: req_01HX9Y..." \ -F "to=554137984905" \ -F "caption=Imagem enviada pela API" \ -F "file=@/caminho/foto.png" ``` Exemplo com URL externa: ```bash curl -X POST "https://api.catcher.one/v1/instances/$INSTANCE/messages/video" \ -H "X-API-Key: $TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: req_01HX9Y..." \ -d '{ "to": "554137984905", "media_url": "https://meucdn.com/video.mp4", "caption": "Olha esse vídeo" }' # → 202 só sai depois que os bytes estão no R2. # → 502 MEDIA_FETCH_FAILED se o CDN externo recusar. ``` Exemplo com upload prévio (recomendado para arquivos grandes ou reutilizados): ```bash # 1. Upload uma vez: MEDIA_ID=$(curl -sf -X POST "https://api.catcher.one/v1/instances/$INSTANCE/media" \ -H "X-API-Key: $TOKEN" \ -H "Idempotency-Key: upload-$(date +%s)" \ -F "file=@/caminho/video-grande.mp4" | jq -r .media_id) # 2. Reutilize em N envios sem rebaixar: for PHONE in 554137984905 554196332719 554163475715; do curl -X POST "https://api.catcher.one/v1/instances/$INSTANCE/messages/video" \ -H "X-API-Key: $TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: bulk-$PHONE-$(date +%s)" \ -d "{\"to\":\"$PHONE\",\"media_id\":\"$MEDIA_ID\",\"caption\":\"Veja como ficou\"}" done ``` > Em JSON, envie `media_url` OU `media_id`. Em multipart, envie `file`. Um desses tres e obrigatório. --- ### POST /v1/instances/{instanceId}/messages/image Envia imagem. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "media_url": "https://exemplo.com/foto.png", "caption": "Confira esta imagem!", "quoted_id": "", "mentions": [] } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `media_url` | string | sim* | URL pública da imagem | | `media_id` | string | sim* | ID de média pre-enviado via upload | | `file` | file | sim* | Arquivo direto via `multipart/form-data` | | `caption` | string | não | Legenda da imagem | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs mencionados | > *Enviar `media_url`, `media_id` OU `file` (um dos tres e obrigatório). --- ### POST /v1/instances/{instanceId}/messages/video Envia video. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "media_url": "https://exemplo.com/video.mp4", "caption": "Assista ao video" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `media_url` | string | sim* | URL pública do video | | `media_id` | string | sim* | ID de média pre-enviado | | `file` | file | sim* | Arquivo direto via `multipart/form-data` | | `caption` | string | não | Legenda | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs mencionados | > *Enviar `media_url`, `media_id` OU `file` (um dos tres e obrigatório). --- ### POST /v1/instances/{instanceId}/messages/audio Envia audio. Por padrão, todos os audios são enviados como nota de voz (PTT). Formatos não-OGG (MP3, M4A, WAV, WebM, etc) são convertidos automaticamente para OGG Opus (mono, 16 kHz, 32 kbps) via ffmpeg — formato exigido pelo WhatsApp mobile para PTT. Uma waveform de 64 bytes e gerada automaticamente para exibição no app. Use `ptt: false` para enviar como audio regular sem conversao. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "media_url": "https://exemplo.com/audio.mp3" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `media_url` | string | sim* | URL pública do audio (qualquer formato: MP3, OGG, M4A, WAV, etc) | | `media_id` | string | sim* | ID de média pre-enviado | | `file` | file | sim* | Arquivo direto via `multipart/form-data` | | `ptt` | bool | não | Padrão `true` (nota de voz). Converte automaticamente para OGG Opus se necessário. `false` = audio regular, sem conversao | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs mencionados | > *Enviar `media_url`, `media_id` OU `file` (um dos tres e obrigatório). --- ### POST /v1/instances/{instanceId}/messages/document Envia documento (PDF, DOCX, etc). **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "media_url": "https://exemplo.com/relatorio.pdf", "file_name": "relatorio-marco.pdf" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `media_url` | string | sim* | URL pública do documento | | `media_id` | string | sim* | ID de média pre-enviado | | `file` | file | sim* | Arquivo direto via `multipart/form-data` | | `file_name` | string | não | Nome do arquivo exibido no WhatsApp | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs mencionados | > *Enviar `media_url`, `media_id` OU `file` (um dos tres e obrigatório). --- ### POST /v1/instances/{instanceId}/messages/sticker Envia sticker (figurinha). A imagem deve estar no formato **WebP**. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "media_url": "https://exemplo.com/sticker.webp" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `media_url` | string | sim* | URL pública do sticker (WebP) | | `media_id` | string | sim* | ID de média pre-enviado | | `file` | file | sim* | Arquivo direto via `multipart/form-data` | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs para mencionar | > *Enviar `media_url`, `media_id` OU `file` (um dos tres e obrigatório). --- ### POST /v1/instances/{instanceId}/messages/location Envia localização. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "latitude": -25.4284, "longitude": -49.2733, "name": "Praca Tiradentes", "address": "Centro, Curitiba - PR" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `latitude` | float | sim | Latitude | | `longitude` | float | sim | Longitude | | `name` | string | não | Nome do local | | `address` | string | não | Endereço | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs para mencionar | --- ### POST /v1/instances/{instanceId}/messages/contact Envia cartao de contato (vCard). **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "contact_name": "Suporte BiaZap", "contact_phone": "5541988887777" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `contact_name` | string | sim | Nome do contato no cartao | | `contact_phone` | string | sim | Telefone do contato | | `quoted_id` | string | não | ID da mensagem respondida (UUID BiaZap ou hex WhatsApp; ver nota geral de `quoted_id` acima) | | `mentions` | []string | não | JIDs para mencionar | --- ### POST /v1/instances/{instanceId}/messages/reaction Envia reacao (emoji) a uma mensagem. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "message_id": "3EB0ABC123DEF456", "emoji": "👍" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | JID do chat onde está a mensagem | | `message_id` | string | sim | ID da mensagem no WhatsApp | | `emoji` | string | sim | Emoji da reacao (string vazia para remover) | --- ### POST /v1/instances/{instanceId}/messages/poll Cria uma enquete. **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "question": "Qual o melhor dia para a reuniao?", "options": ["Segunda", "Terca", "Quarta"], "max_selections": 1 } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `question` | string | sim | Pergunta da enquete | | `options` | []string | sim | Opcoes (mínimo 2) | | `max_selections` | int | não | Máximo de selecoes por pessoa. Padrão: 1 | --- ### POST /v1/instances/{instanceId}/messages/template Envia mensagem com botoes (template). **Requer conta WhatsApp Business.** **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "body_text": "Escolha uma opcao abaixo:", "footer_text": "Powered by BiaZap", "buttons": [ {"text": "Comprar", "id": "btn_buy"}, {"text": "Cancelar", "id": "btn_cancel"} ] } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `body_text` | string | sim | Texto principal | | `footer_text` | string | não | Texto do rodape | | `buttons` | []object | sim | 1 a 3 botoes | | `buttons[].text` | string | sim | Texto exibido no botao | | `buttons[].id` | string | sim | ID do botao (retornado no callback) | > **Nota:** Se a instância não for uma conta Business, a mensagem será rejeitada sem retry. --- ### POST /v1/instances/{instanceId}/messages/link-preview Envia uma mensagem de texto com **card de preview de link controlado** (você define título e descrição). Renderiza no destinatário (tipo nativo `ExtendedTextMessage`). **Auth:** Todos autenticados · **Header:** `Idempotency-Key` obrigatório > **Preview NÃO é o default.** Um link num `POST /messages/text` comum vai como texto puro — o app do destinatário PODE gerar um preview básico do lado dele (fetch client-side), mas isso não é garantido nem controlado por você. Para um card com título/descrição garantidos, use este endpoint. **Request:** ```json { "to": "554137984905", "text": "Conhece a Catcher? https://catcher.one", "url": "https://catcher.one", "title": "Catcher", "description": "WhatsApp API multi-tenant" } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `text` | string | sim | Corpo da mensagem (deve conter a URL) | | `url` | string | sim | URL do preview | | `title` | string | não | Título do card | | `description` | string | não | Descrição do card | | `quoted_id` | string | não | UUID Catcher OU hex WhatsApp da mensagem citada | Retorna `202` com `message_id` (UUID Catcher) + `task_id` + `idempotency_key`. Emite `message.sent` (`message_type: text`). --- ### POST /v1/instances/{instanceId}/messages/event Envia uma **mensagem de evento** (calendário). Tipo nativo `EventMessage` — renderiza no destinatário. **Auth:** Todos autenticados · **Header:** `Idempotency-Key` obrigatório **Request:** ```json { "to": "554137984905", "name": "Reunião de planejamento", "description": "pauta trimestral", "start_at": "2026-05-30T15:00:00Z", "end_at": "2026-05-30T16:00:00Z", "location": "Online" } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário (1:1 ou grupo `@g.us`) | | `name` | string | sim | Nome do evento | | `start_at` | string | sim | Início (RFC3339) | | `description` | string | não | Descrição | | `end_at` | string | não | Fim (RFC3339) | | `location` | string | não | Local | | `link` | string | não | Link de chamada/join | Retorna `202`. Emite `message.sent` (`message_type: event`). --- ### POST /v1/instances/{instanceId}/messages/ptv Envia um **vídeo redondo (PTV / "video note")**. Mesma origem de mídia dos outros sends (`media_url` / `media_id` / multipart). Tipo nativo `PtvMessage`. ### POST /v1/instances/{instanceId}/messages/gif Envia um **vídeo animado marcado como GIF** (autoplay/loop no destinatário, `gifPlayback=true`). Mesma origem de mídia. Ambos: `202` + `Idempotency-Key` obrigatório; emitem `message.sent` (`message_type: ptv` / `gif`). --- ### view_once (flag de mídia) `POST /messages/image`, `/video` e `/audio` aceitam `"view_once": true` no corpo — a mídia vira **visualização única** (`viewOnceMessage`, auto-destrói após aberta). Default `false`. ```json { "to": "554137984905", "media_url": "https://...", "view_once": true } ``` --- ### Status / Stories (§13.3) Posta um **status (story)**. Nativo e render-safe: a whatsmeow envia para `status@broadcast` e **resolve a audiência automaticamente** das configurações de privacidade de status do número (quem vê), cifrando o fan-out. Sem `to` — status é um broadcast. **Anti-spam guard não se aplica** (broadcast, não 1:1); o throttle de pacing continua valendo. **Auth:** Todos autenticados · **Header:** `Idempotency-Key` obrigatório nos POSTs. #### POST /v1/instances/{instanceId}/messages/status/text ```json { "text": "Meu status ✨", "background_color": "#1FA75B", "font": 6 } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|:-----------:|-----------| | `text` | string | sim | Texto do status | | `background_color` | string | não | Cor de fundo `#RRGGBB` | | `font` | int | não | Fonte (0=sistema, 1=system_text, 2=fb_script, 6=system_bold, 7=morningbreeze, 8=calistoga, 10=courierprime_bold) | #### POST /v1/instances/{instanceId}/messages/status/image #### POST /v1/instances/{instanceId}/messages/status/video ```json { "media_url": "https://...", "caption": "legenda opcional" } ``` Aceita `media_url` (resolvido sincronamente pra `media_id`) OU `media_id` (upload prévio em `/media`). Todos retornam `202` com `message_id` (UUID Catcher); emitem `message.sent` (`message_type: status_text` / `status_image` / `status_video`). #### GET /v1/instances/{instanceId}/status/privacy Retorna quem pode ver o status do número. ```json { "status_privacy": [ { "type": "contacts", "list": [], "is_default": true } ] } ``` `type`: `contacts` | `blacklist` | `whitelist`. `list`: telefones (para black/whitelist). #### GET /v1/instances/{instanceId}/status/{statusId}/views Contagem de visualizações + lista de quem viu UM status seu. `statusId` é o `message_id` retornado ao postar (UUID Catcher) ou o id bruto do WhatsApp. A contagem é de **viewers únicos** (um por contato, mesmo que reabra o status). ```json { "status_message_id": "c710cea9-fcc4-4a7c-9e30-1a19ebc07bfb", "count": 2, "viewers": [ { "phone": "554196332719", "jid": "216251804708983@lid", "receipt_type": "read", "viewed_at": "2026-05-23T18:48:26Z" }, { "phone": "554163475715", "jid": "...@lid", "receipt_type": "played", "viewed_at": "2026-05-23T18:49:01Z" } ] } ``` `receipt_type`: `read` (status aberto) | `played` (vídeo assistido). Sem visualizações ainda → `count: 0`, `viewers: []`. #### Webhooks de status — recepção (interações com seu status) A Catcher emite um evento dedicado para cada interação. **Compartilhamento não é observável pelo autor** (o WhatsApp não notifica o autor quando alguém compartilha). > **Semântica de público (audience) — vale para TODOS os eventos de recepção abaixo.** > Status são entregues ao **público** do autor: contatos **salvos na agenda** do > número, filtrados pela privacidade de status ("Meus contatos" / exceções). Você > só recebe `status.received` de um contato cujo status alcança a sua instância, e > só recebe `status.viewed`/`status.reaction`/`status.reply` de quem está no SEU > público. Trocar DM com um número **não** o coloca no público de status de nenhum > dos lados — é preciso estar salvo na agenda. Em testes entre instâncias de API > que não se têm salvas, o `POST .../status/*` envia com sucesso (você vê > `message.sent` no autor), mas a outra instância não emite `status.received` — isso > é esperado, não é bug. Um aviso `Server returned different participant list hash …` > no log do worker é a computação do público, não uma falha de envio. **`status.received`** — um contato postou um status/story (antes era descartado): ```json { "phone": "5511999999999", "from": "5511999999999@s.whatsapp.net", "status_message_id": "3EB0...", "type": "text|image|video|audio", "content": "...", "timestamp": 1779543210 } ``` > Convenção de campos (igual a todos os eventos): `phone` = telefone do > interagente (resolvido), `from` = JID dele (pode ser um LID em status@broadcast). **`status.viewed`** — um contato VISUALIZOU um status SEU (recibo read/played): ```json { "phone": "554196332719", "from": "216251804708983@lid", "status_message_id": "c710cea9-...", "whatsapp_status_id": "3EB0...", "status_type": "image", "receipt_type": "read", "timestamp": 1779565914 } ``` **`status.reaction`** — um contato REAGIU a um status SEU: ```json { "phone": "554196332719", "from": "216251804708983@lid", "push_name": "Adriano", "status_message_id": "c710cea9-...", "whatsapp_status_id": "3EB0...", "status_type": "image", "emoji": "💚", "removed": false, "timestamp": 1779565792 } ``` `removed: true` (e `emoji: ""`) quando a reação foi removida. **`status.reply`** — um contato RESPONDEU/comentou um status SEU. A `message.received` do DM continua disparando normalmente; este evento traz o contexto do status: ```json { "phone": "554196332719", "from": "216251804708983@lid", "push_name": "Adriano", "status_message_id": "c710cea9-...", "whatsapp_status_id": "3EB0...", "status_type": "image", "reply_message_id": "0a279...", "whatsapp_reply_id": "3A2D...", "reply_type": "text", "content": "😍", "timestamp": 1779566005 } ``` --- ### Mensagens interativas (botões / PIX / OTP / carrossel / lista) — **DESATIVADAS** Os endpoints `POST /messages/button`, `/button-pix`, `/button-otp` e `/option-list` existem no código mas estão **desativados** e retornam `403 INTERACTIVE_DISABLED`. **Por quê:** o spike de 2026-05-23 provou que os clientes WhatsApp do destinatário **descartam silenciosamente** mensagens interativas (`InteractiveMessage`/`ListMessage`) enviadas pelo protocolo multi-device não-oficial — não renderizam, nem como texto. O Baileys (referência da indústria) nunca shipou envio de botão pelo mesmo motivo. Enviar tipos não suportados é risco de ban sem ganho. Ficam prontos para reativar se a Meta passar a suportar. Detalhe em `docs/COMPARATIVO-ZAPI-BIAZAP.md` §15.9. --- ### POST /v1/instances/{instanceId}/messages/forward Encaminha uma mensagem existente para outro chat. **Auth:** Todos autenticados **Request:** ```json { "to": "5541988888888", "forward_chat_id": "554137984905@s.whatsapp.net", "message_id": "3EB0ABC123DEF456" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário do encaminhamento | | `forward_chat_id` | string | sim | JID do chat onde a mensagem original está | | `message_id` | string | sim | ID da mensagem a encaminhar | > Funciona com todos os tipos: texto, imagem, video, audio, documento. --- ### DELETE /v1/instances/{instanceId}/messages/{messageId} Apaga uma mensagem enviada ("apagar para todos"). **Auth:** Todos autenticados **Request:** ```json { "to": "554137984905", "message_id": "3EB0ABC123DEF456" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | JID do chat | | `message_id` | string | sim | ID da mensagem no WhatsApp | **Resposta 202:** ```json { "status": "queued", "task_id": "asynq:task:xxxx" } ``` --- ### PUT /v1/instances/{instanceId}/messages/{messageId}/edit Edita o texto de uma mensagem enviada. Somente mensagens de texto outbound podem ser editadas. O WhatsApp impoe um limite de tempo (~15 minutos após o envio). **Auth:** Todos autenticados **Request:** ```json { "content": "Texto atualizado da mensagem" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `content` | string | sim | Novo conteúdo de texto da mensagem | **Resposta 202:** ```json { "status": "queued", "task_id": "asynq:task:xxxx" } ``` **Erros:** | Código | Descricao | |--------|-----------| | 400 | `content is required` — conteúdo vazio | | 400 | `only outbound messages can be edited` — mensagem não foi enviada pela instância | | 400 | `only text messages can be edited` — tipo não e texto | | 400 | `cannot edit a deleted message` — mensagem já foi deletada | | 404 | `message not found` — `messageId` não encontrado | > O `messageId` na URL e o ID do WhatsApp (`whats_app_id`), não o ID interno. Após a edicao, o webhook `message.edited` e emitido com `is_from_me: true`. --- ### 7.X Ações sobre mensagens (Wave 1 — Z-API parity) Três endpoints síncronos que operam sobre uma mensagem existente: fixar (pin), votar em enquete (poll-vote) e favoritar (star). Diferente dos sends de conteúdo, esses endpoints respondem `200 OK` (não `202`) porque atuam direto via RPC no worker — sem fila Asynq, sem `Idempotency-Key` obrigatório, sem anti-spam guard pesado. Os 3 endpoints aceitam o `message_id` em qualquer formato (UUID Catcher OU hex WhatsApp). A API resolve internamente. --- #### POST /v1/instances/{instanceId}/messages/pin Fixa ou desfixa uma mensagem no chat para todos os participantes. Em grupos, requer que a instância seja admin. **Request:** ```json { "chat_jid": "554163475715@s.whatsapp.net", "message_id": "198d2663-d6a7-4d62-a018-963065c80403", "target_sender_jid": "", "target_from_me": true, "pin": true, "duration_seconds": 86400 } ``` | Campo | Tipo | Obrigatório | Descrição | |---|---|---|---| | `chat_jid` | string | sim | Chat onde a mensagem está. | | `message_id` | string | sim | UUID Catcher OU hex WhatsApp da mensagem-alvo. | | `target_sender_jid` | string | não | JID de quem enviou a mensagem-alvo. Em DM, default = `chat_jid`. **Obrigatório em grupos.** | | `target_from_me` | bool | não | Se a mensagem foi enviada pela instância. Default `false`. | | `pin` | bool | sim | `true` = PIN_FOR_ALL, `false` = UNPIN_FOR_ALL. | | `duration_seconds` | int | não | `86400` (24h) / `604800` (7d) / `2592000` (30d). `0` = WhatsApp default (24h). Ignorado em unpin. | **Response `200 OK`:** ```json { "instance_id": "48a1322e-f3b1-4a32-ac79-58f67036af79", "chat_jid": "554163475715@s.whatsapp.net", "target_message_id": "3EB01870039BE5362C6F0A", "action": "pinned", "envelope_message_id": "3EB0ABC123..." } ``` `envelope_message_id` é o ID do WhatsApp da própria mensagem PIN (não da mensagem fixada). --- #### POST /v1/instances/{instanceId}/messages/poll-vote Vota em uma enquete existente. A instância precisa ter recebido a enquete (whatsmeow precisa do segredo da poll pra re-encriptar). **Request:** ```json { "chat_jid": "554163475715@s.whatsapp.net", "poll_message_id": "3EB0CAFEBABE123", "poll_sender_jid": "", "poll_from_me": false, "selected_options": ["sim", "muito legal"] } ``` | Campo | Tipo | Obrigatório | Descrição | |---|---|---|---| | `chat_jid` | string | sim | Chat onde a poll está. | | `poll_message_id` | string | sim | UUID Catcher OU hex WhatsApp da mensagem de criação da poll. | | `poll_sender_jid` | string | não | Quem criou a poll. Em DM, default = `chat_jid`. | | `poll_from_me` | bool | não | Se a instância criou a poll. Default `false`. | | `selected_options` | array de strings | sim | Nomes exatos das opções votadas. `[]` vazio limpa o voto. | **Response `200 OK`:** ```json { "instance_id": "...", "chat_jid": "554163475715@s.whatsapp.net", "poll_message_id": "3EB0CAFEBABE123", "selected_options": ["sim", "muito legal"], "envelope_message_id": "3EB0VOTE..." } ``` > Voto é uma `PollUpdateMessage` cifrada com o segredo da poll original. Se a instância não tem o segredo (poll foi enviada antes da instância existir, ou após reset de sessão), o envio falha com `404`. --- #### POST /v1/instances/{instanceId}/messages/star Favorita ou desfavorita uma mensagem. Muda só no AppState da instância — propaga pros outros devices via sync (não é visível pro destinatário). **Request:** ```json { "chat_jid": "554163475715@s.whatsapp.net", "message_id": "198d2663-d6a7-4d62-a018-963065c80403", "sender_jid": "", "from_me": true, "starred": true } ``` | Campo | Tipo | Obrigatório | Descrição | |---|---|---|---| | `chat_jid` | string | sim | Chat onde a mensagem está. | | `message_id` | string | sim | UUID Catcher OU hex WhatsApp. | | `sender_jid` | string | não | Quem enviou. Em DM, default = `chat_jid`. **Obrigatório em grupos.** | | `from_me` | bool | não | Se a instância enviou. Default `false`. | | `starred` | bool | sim | `true` = favoritar, `false` = desfavoritar. | **Response `200 OK`:** ```json { "instance_id": "...", "chat_jid": "554163475715@s.whatsapp.net", "message_id": "3EB01870...0A", "action": "starred" } ``` > Mudança propaga pros outros devices da operadora via `appstate sync`. Webhook `message.starred` é emitido quando a propagação retorna pelo whatsmeow. --- ## 7.W WhatsApp Business — Labels, Notas de Chat, Perfil (§13.2) Recursos de WhatsApp Business nativos + render-safe (validados via `/baileys-reference`: whatsmeow expõe os builders de app-state de label + `GetBusinessProfile`). **Leitura de catálogo/coleções de produtos** foi adicionada via fork patch #11 (port fiel das IQ queries do Baileys — `w:biz:catalog`). As imagens de produto são proxy-cacheadas (R2), nunca URL da CDN da Meta. `send-product/catalog-message` e CRUD de produto via API ficam de fora por ora (o fork já tem os métodos de escrita prontos). Mensagens de pedido recebidas já viram evento (`order_received`/`product_received`). ### Labels (etiquetas do WhatsApp Business) Etiquetas são app-state: criar/editar/excluir e associar a chat/mensagem propagam pelos devices da operadora e renderizam no app WhatsApp Business. O servidor ecoa cada mudança de volta como evento `label.*` (que a Catcher persiste); a API também persiste na hora pra leitura imediata. | Método | Rota | Corpo / efeito | |---|---|---| | `POST` | `/v1/instances/{id}/labels` | `{ "name": "Lead Quente", "color": 3 }` → cria etiqueta. `label_id` é auto-alocado (primeira vaga livre 1..20). `color` 0..19. Retorna `201 {label_id,name,color}`. | | `GET` | `/v1/instances/{id}/labels` | Lista etiquetas ativas (inclui as sincronizadas do device). `{ "labels": [{label_id,name,color}] }`. | | `PATCH` | `/v1/instances/{id}/labels/{labelId}` | `{ "name", "color" }` → renomeia/recolore. `404` se a etiqueta não existe. | | `DELETE` | `/v1/instances/{id}/labels/{labelId}` | Exclui a etiqueta **e suas associações** (consistente com o WhatsApp). `204`. | | `POST` | `/v1/instances/{id}/chats/{chatId}/labels/{labelId}` | Aplica a etiqueta no chat. `chatId` é normalizado (BR). `200 {label_id,chat,associated:true}`. `404` se a etiqueta não existe. | | `DELETE` | `/v1/instances/{id}/chats/{chatId}/labels/{labelId}` | Remove a etiqueta do chat. `200`. | | `GET` | `/v1/instances/{id}/labels/{labelId}/chats` | Lista os chats com a etiqueta (DISTINCT, JIDs normalizados). `{ "label_id", "chats": [...] }`. | | `POST` | `/v1/instances/{id}/messages/{messageId}/labels/{labelId}` | Aplica a etiqueta numa mensagem. `messageId` aceita UUID Catcher ou hex WhatsApp (resolvido pro chat+hex). `200`. | | `DELETE` | `/v1/instances/{id}/messages/{messageId}/labels/{labelId}` | Remove a etiqueta da mensagem. `200`. | > Limite WhatsApp: 20 etiquetas por conta. `POST /labels` retorna `400 "label limit reached"` quando as 20 vagas estão em uso. > A listagem `labels/{id}/chats` reflete o estado sincronizado com o WhatsApp (eco do app-state, ~1s) — a Catcher também grava na hora pra leitura imediata; os dois convergem (JID normalizado + DISTINCT). ### Notas de chat (exclusivo Catcher) Notas privadas do operador num chat. **Ficam só no banco da Catcher, nunca são enviadas pro WhatsApp** (sem caminho whatsmeow, zero risco de ban) — visíveis apenas no Console. Diferencial sobre a Z-API: carregam autor + timestamps. | Método | Rota | Corpo | |---|---|---| | `POST` | `/v1/instances/{id}/chats/{chatId}/notes` | `{ "note": "cliente prefere contato à tarde" }` → `201 {id,chat_jid,note,author_user_id,created_at,updated_at}` | | `GET` | `/v1/instances/{id}/chats/{chatId}/notes` | `{ "notes": [...] }` (mais recente primeiro) | | `PATCH` | `/v1/instances/{id}/chats/{chatId}/notes/{noteId}` | `{ "note": "..." }` → `200`. `404` se não existe. | | `DELETE` | `/v1/instances/{id}/chats/{chatId}/notes/{noteId}` | `204`. | `note` máx 8192 chars; `author_user_id` vem do JWT. Escopo por `(instance_id, chat_jid)`. ### Perfil Business (somente leitura) | Método | Rota | Descrição | |---|---|---| | `GET` | `/v1/instances/{id}/business/profile?jid=` | Lê o perfil business: `{jid, address, email, categories:[{id,name}], business_hours_timezone, business_hours:[{day_of_week,mode,open_time,close_time}]}`. `jid` vazio = perfil da própria instância; senão, de qualquer número business. | > **SET/edição do perfil business NÃO existe** via multi-device (nem whatsmeow nem Baileys expõem) — é território de Cloud API / app WhatsApp Business. Por isso só há leitura. ### Catálogo de produtos (somente leitura) Lê o catálogo de produtos e as coleções de qualquer conta WhatsApp Business (ou da própria instância). Implementado via fork patch #11 — port fiel das IQ queries do Baileys no namespace `w:biz:catalog`. | Método | Rota | Descrição | |---|---|---| | `GET` | `/v1/instances/{id}/business/catalog?jid=&limit=&cursor=` | Lê uma página do catálogo. `jid` vazio = catálogo da própria instância; senão, de qualquer número business. `limit` (padrão 10) limita a página; `cursor` (de `next_page_cursor`) pagina. Retorna `{products:[...], next_page_cursor}`. Business sem catálogo → `200 {products:[]}`. | | `GET` | `/v1/instances/{id}/business/collections?jid=&limit=` | Lê as coleções (agrupamentos de produtos) do catálogo. `limit` (padrão 51). Retorna `{collections:[{id,name,status,products:[...]}]}`. | Cada produto: `{id, name, description, retailer_id, url, price, currency, is_hidden, review_status, image_url}`. `price` é inteiro na menor unidade da moeda (ex.: `4990` = R$ 49,90 quando `currency:"BRL"`). > **`image_url` é sempre URL controlada pela Catcher (R2), nunca da CDN da Meta.** A imagem primária de cada produto é baixada pelo worker através do túnel/proxy da instância e cacheada no R2 (best-effort: vazia em falha de cache). O browser do consumidor nunca fala direto com `mmg.whatsapp.net`/`fbcdn.net` (lição #31/#35). Validado ao vivo em 2026-05-24 contra um produto real (Catcher 3). > **Catálogo é SOMENTE LEITURA.** Criar/editar/remover produto e `send catalog/product message` **NÃO estão disponíveis**. A escrita (`product_catalog_add`/`_edit`, type=set) foi implementada e validada até o servidor do WhatsApp, mas o IQ de escrita não retorna resposta que o whatsmeow reconheça — mesmo numa conta commerce habilitada — então foi descontinuada (2026-05-24). Detalhes: lição #51 + `.claude/rules/whatsmeow-fork.md` patch #11. --- ## 7.X Comunidades (§13.3) Comunidades do WhatsApp (recurso 2022+): um grupo "pai" (announcement hub) que agrupa sub-grupos. Criar uma comunidade faz o servidor auto-criar o grupo de avisos (announcement) — enviar pra ele transmite pra TODA a comunidade. Native whatsmeow, render-safe. Validado E2E 2026-05-25. | Método | Rota | Corpo / efeito | |---|---|---| | `GET` | `/v1/instances/{id}/communities` | Lista as comunidades (grupos "pai") em que a instância está. `{ "communities": [{jid, name, description, is_community:true, participants}] }`. Filtra `GetJoinedGroups` por `is_parent`. | | `POST` | `/v1/instances/{id}/communities` | `{ "name": "Minha Comunidade", "description": "..." }` → cria a comunidade. Retorna `201 {jid, name, description, announcement_jid}`. **`announcement_jid`** é o grupo de avisos auto-criado — envie texto/mídia pra esse JID (qualquer endpoint de send normal) pra fazer broadcast pra toda a comunidade. `name` 1..100. | | `GET` | `/v1/instances/{id}/communities/{communityId}/groups` | Lista os sub-grupos vinculados. `{ "sub_groups": [{jid, name, is_announcement}] }`. `is_announcement:true` marca o grupo de avisos (alvo do broadcast). | | `POST` | `/v1/instances/{id}/communities/{communityId}/groups/{groupId}` | Vincula um grupo EXISTENTE como sub-grupo da comunidade. `200 {community_jid, group_jid, linked:true}`. | | `DELETE` | `/v1/instances/{id}/communities/{communityId}/groups/{groupId}` | Desvincula o sub-grupo. `200 {..., linked:false}`. | Detalhe da comunidade: use `GET /v1/instances/{id}/groups/{communityId}` — o group info traz `is_community:true` no pai e `linked_parent_jid` nos sub-grupos. Eventos emitidos (everything-is-an-event): `community.created_sent`, `community.group_linked_sent`, `community.group_unlinked_sent`. > **Broadcast** = enviar pro `announcement_jid` via qualquer `POST .../messages/*`. Não há endpoint dedicado de "announce" — é um send normal pro JID do grupo de avisos. > **Deferido:** criar sub-grupo já-vinculado num único call, PATCH de nome/descrição/foto da comunidade, invite-link/admins/deactivate da comunidade (reusam ops de grupo). _Deletar uma comunidade não tem endpoint dedicado — é uma ação destrutiva do dono pelo app WhatsApp (o "leave" do criador não dissolve a comunidade)._ --- ## 8. Mensagens Agendadas ### POST /v1/instances/{instanceId}/messages/scheduled Agenda uma mensagem para envio futuro. **Auth:** Todos autenticados **Header obrigatório:** `Idempotency-Key` **Request:** ```json { "to": "554137984905", "message_type": "text", "payload": { "text": "Lembrete: sua reuniao e amanha!" }, "schedule_at": "2026-03-08T14:00:00Z" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário | | `message_type` | string | sim | Tipo: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `reaction`, `poll`, `template` | | `payload` | object | sim | Conteúdo da mensagem (mesmos campos do tipo correspondente, sem o campo `to`) | | `schedule_at` | string | sim | Data/hora no formato RFC3339 (deve ser no futuro) | **Resposta 202:** ```json { "id": "sched_xxxx", "status": "scheduled", "schedule_at": "2026-03-08T14:00:00Z", "task_id": "asynq:task:xxxx", "idempotency_key": "sched-2026-03-21-0001" } ``` > Repetir a mesma chave para a mesma instância + tipo de mensagem reutiliza o mesmo agendamento, em vez de criar outro registro. --- ### GET /v1/instances/{instanceId}/messages/scheduled Lista mensagens agendadas e já processadas da instância. **Auth:** Todos autenticados **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `per_page` | int | 20 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "id": "sched_xxxx", "to": "554137984905", "message_type": "text", "schedule_at": "2026-03-08T14:00:00Z", "status": "scheduled", "created_at": "2026-03-07T10:00:00Z" } ], "page": 1, "per_page": 20 } ``` **Status possíveis:** `scheduled`, `processing`, `sent`, `failed`, `cancelled` --- ### DELETE /v1/instances/{instanceId}/messages/scheduled/{scheduledId} Cancela uma mensagem agendada. **Auth:** Todos autenticados **Resposta 200:** ```json { "id": "sched_xxxx", "status": "cancelled" } ``` --- ## 9. Mídia (Upload/Download) ### POST /v1/instances/{instanceId}/média Faz upload de arquivo para o S3. Retorna um `media_id` (UUID) que pode ser usado nos endpoints de mensagem via o campo `media_id`. Este endpoint e opcional para fluxos que querem preparar/reutilizar mídias; para envio simples, os endpoints `messages/image`, `messages/video`, `messages/audio`, `messages/document` e `messages/sticker` também aceitam `media_url` ou `multipart/form-data` direto. **Auth:** Todos autenticados **Limites:** | Limite | Valor | |---|---| | Tamanho máximo por arquivo | **64 MB** | | Content-Type aceito | `multipart/form-data` OU `application/json` | | MIME types aceitos | `image/*`, `video/*`, `audio/*`, `application/pdf` (qualquer outro retorna `400 unsupported media MIME type`) | #### Opcao 1: Multipart upload (recomendado para upload direto) ``` Content-Type: multipart/form-data ``` Form field: `file` (arquivo binario, max 64 MB) Exemplo: ```bash curl -X POST https://api.catcher.one/v1/instances/$INSTANCE/media \ -H "X-API-Key: $TOKEN" \ -F "file=@/caminho/para/documento.pdf" ``` #### Opcao 2: Upload via URL (o servidor baixa do link) ``` Content-Type: application/json ``` ```json { "media_url": "https://exemplo.com/imagem.png", "file_name": "imagem.png" } ``` Util quando o arquivo já está em um bucket público (R2, S3 com signed URL, etc). O servidor baixa com timeout de 30s, aplica o mesmo limite de 64 MB, e rejeita MIME type fora da allowlist. URLs em redes privadas / IPs internos / Meta CDN são rejeitadas (SSRF guard + proxy discipline). **Resposta 201:** ```json { "media_id": "68854a58-c8c9-4021-9c6f-765dc5cdff34", "mime_type": "image/png", "file_name": "imagem.png", "file_size": 45678 } ``` > `media_id` e um UUID v4 — use como `media_id` no body de qualquer endpoint `POST /v1/instances/{instanceId}/messages/{image|video|audio|document|sticker}` dentro dos **7 dias** de retencao padrão, ou envie a mídia direto nesses endpoints via `media_url`/`file`. **Erros:** | Status | `error_code` | Descricao | |---|---|---| | 400 | `BAD_REQUEST` | `file required` (multipart sem o campo `file`), `failed to read file`, `file too large (max 64MB)`, `unsupported media MIME type`, `invalid JSON`, `media_url or file upload required`, `failed to parse multipart form` | | 401 | `UNAUTHORIZED` | Sem `Authorization: Bearer` nem `X-API-Key`, ou credencial inválida | | 404 | `NOT_FOUND` | `instance not found` (instância não pertence a sua empresa) | | 500 | `INTERNAL_SERVER_ERROR` | `upload failed` (S3 indisponível), `failed to save media record` (DB indisponível) | | 503 | `SERVICE_UNAVAILABLE` | `media storage not configured` (S3 não wired no backend — não deve ocorrer em staging/prod) | --- ### GET /v1/instances/{instanceId}/média/{mediaId} Faz download de um arquivo de mídia. Funciona tanto para mídia **enviada** (upload via POST) quanto para mídia **recebida** (baixada automaticamente de mensagens inbound do WhatsApp). **Auth:** Todos autenticados **Parametros de URL:** | Parametro | Descricao | |-----------|-----------| | `instanceId` | ID da instância | | `mediaId` | UUID da mídia (retornado no upload, no campo `media_id` do evento `message.received`, ou no campo `media_id` do objeto de mensagem) | **Resposta 200:** Arquivo binario com headers: - `Content-Type`: MIME type do arquivo (ex: `image/png`, `audio/ogg; codecs=opus`) - `Content-Disposition`: `inline; filename="imagem.png"` - `Content-Length`: Tamanho em bytes **Erros:** | Status | Descricao | |--------|-----------| | 404 | Mídia não encontrada | | 503 | Armazenamento S3 não configurado | **Exemplo de uso (download de audio recebido):** ```bash # 1. Receba um evento message.received via webhook/SSE com media_id # 2. Baixe o arquivo: curl -o audio.ogg \ -H "X-API-Key: $TOKEN" \ "https://api.catcher.one/v1/instances/$INSTANCE/media/b2c3d4e5-f6a7-8901-bcde-f12345678901" ``` > **Nota:** Toda mídia recebida no WhatsApp (imagens, videos, audios, documentos, stickers) e baixada automaticamente do WhatsApp e armazenada no S3. O `media_id` e incluído no evento `message.received` e no objeto de mensagem retornado pelos endpoints de chat/mensagens. --- ### GET /v1/instances/{instanceId}/média/{mediaId}/info Retorna **apenas metadata** da mídia (MIME, tamanho, filename, timestamps) como JSON, sem baixar o binario. Use este endpoint para espelhar informações da mídia no seu banco ou decidir se vale a pena baixar o arquivo antes de puxar os bytes. **Auth:** Todos autenticados **Parametros de URL:** mesmos do endpoint de download (`instanceId`, `mediaId`). **Resposta 200:** ```json { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "mime_type": "image/jpeg", "file_name": "foto.jpg", "file_size": 102400, "download_url": "/v1/instances/84c2e480-.../media/b2c3d4e5-...", "info_url": "/v1/instances/84c2e480-.../media/b2c3d4e5-.../info", "created_at": "2026-03-28T14:30:00Z" } ``` Quando a mídia e temporaria (upload com TTL), o campo `expires_at` acompanha. O mesmo shape aparece inline no campo `media` da resposta de `GET /v1/instances/{instanceId}/message/{messageId}` — consumidores podem usar as mesmas chaves em ambos os contextos. **Erros:** | Status | Descricao | |--------|-----------| | 404 | Mídia não encontrada para está instância | --- ### GET /v1/instances/{instanceId}/média Lista paginada de mídias da instância. Cada item inclui `remote_jid` da conversa associada (quando a mídia está vinculada a uma mensagem). **Auth:** Todos autenticados **Query params:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `page` | int | Página (default: 1) | | `limit` | int | Itens por página (default: 50, max: 100) | | `mime_type` | string | Filtro por prefixo MIME (ex: `image/`, `video/`) | | `remote_jid` | string | Filtra mídias de uma conversa especifica (ex: `5511999887766@s.whatsapp.net`) | **Resposta 200:** ```json { "data": [ { "id": "uuid-media", "instance_id": "uuid-instance", "s3_key": "company/instance/file.png", "mime_type": "image/png", "file_name": "foto.png", "file_size": 45678, "remote_jid": "5511999887766@s.whatsapp.net", "created_at": "2026-03-28T14:30:00Z" } ], "total": 120, "page": 1, "limit": 50 } ``` > **Nota:** `remote_jid` pode estar ausente para mídias que não estão associadas a nenhuma mensagem (ex: uploads manuais ainda não enviados). --- ### GET /v1/instances/{instanceId}/média/conversations Lista conversas que possuem mídias, com contadores e metadados agregados. Util para agrupar a galeria de mídia por conversa. **Auth:** Todos autenticados **Query params:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `mime_type` | string | Filtro por prefixo MIME (ex: `image/`) | **Resposta 200:** ```json { "data": [ { "remote_jid": "5511999887766@s.whatsapp.net", "media_count": 42, "total_size": 156789012, "last_media_at": "2026-03-28T14:30:00Z", "thumbnail_id": "uuid-da-midia-mais-recente" } ] } ``` | Campo | Descricao | |-------|-----------| | `remote_jid` | JID da conversa | | `media_count` | Quantidade de mídias na conversa | | `total_size` | Tamanho total em bytes | | `last_media_at` | Data da mídia mais recente | | `thumbnail_id` | UUID da mídia mais recente (pode ser usado para thumbnail via `GET .../media/{thumbnail_id}`) | --- ## 10. Chats ### GET /v1/instances/{instanceId}/chats Lista chats da instância. **Auth:** Todos autenticados Superadmins resolvem o tenant pelo `instanceId`; se a instância não existir ou estiver removida, a resposta e `404 INSTANCE_NOT_FOUND` em vez de erro interno de tenant. **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 20 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "jid": "554137984905@s.whatsapp.net", "name": "Joao", "profile_pic_url": "https://media.biazap.com/.../avatars/554137984905.jpg", "last_message": { "id": "3EB0ABC123", "content": "Ola!", "type": "text", "timestamp": "2026-03-07T12:00:00Z", "from_me": false } } ], "total": 25, "page": 1, "limit": 20 } ``` --- ### GET /v1/instances/{instanceId}/chats/{chatId} Retorna detalhes de um chat especifico, incluindo informações do contato. **Auth:** Todos autenticados **Resposta 200:** ```json { "jid": "554137984905@s.whatsapp.net", "name": "Joao", "push_name": "Joao Silva", "business_name": "", "full_name": "Joao Silva", "picture_url": "https://..." } ``` --- ### GET /v1/instances/{instanceId}/chats/{chatId}/messages Lista mensagens de um chat com paginação. **Auth:** Todos autenticados **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 20 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "id": 1, "instance_id": "84c2e480-...", "direction": "inbound", "remote_jid": "554137984905@s.whatsapp.net", "message_type": "text", "content": "Ola!", "status": "delivered", "whatsapp_id": "3EB0ABC123", "created_at": "2026-03-07T12:00:00Z", "updated_at": "2026-03-07T12:00:00Z", "media_id": "", "quoted_msg_id": "", "queued_at": null, "sent_at": "2026-03-07T12:00:01Z", "delivered_at": "2026-03-07T12:00:02Z", "read_at": null, "failed_at": null, "fail_reason": "" } ], "total": 50, "page": 1, "limit": 20 } ``` --- ### GET /v1/instances/{instanceId}/message/{messageId} Busca uma mensagem especifica pelo UUID do BiaZap (elemento de `message_ids` nos eventos) ou pelo `whatsapp_id` (ID do WhatsApp, ex: `3EB0ABC123`). A API tenta os dois campos automaticamente. Retorna o **estado final** da mensagem no banco do BiaZap — já aplicadas as edicoes (`content` reflete a última versão), revogacoes (`status=deleted`, `is_deleted=true`), e recibos (timestamps de `delivered_at` / `read_at` / `played_at`). Usar este endpoint e o caminho canonico para um consumidor reconstruir o estado atual de uma mensagem sem precisar agregar todos os eventos de webhook. **Auth:** Todos autenticados **Parametros de URL:** | Parametro | Descricao | |-----------|-----------| | `instanceId` | ID da instância | | `messageId` | UUID do BiaZap (elemento de `message_ids` do evento) ou `whatsapp_id` (ex: `3EB0ABC123`) | **Resposta 200:** ```json { "id": 42, "external_id": "550e8400-e29b-41d4-a716-446655440000", "message_id": "550e8400-e29b-41d4-a716-446655440000", "whatsapp_id": "3EB0ABC123DEF456", "instance_id": "84c2e480-...", "direction": "inbound", "remote_jid": "120363025555555555@g.us", "chat": "120363025555555555@g.us", "phone": "120363025555555555", "is_group": true, "is_deleted": false, "sender_jid": "554137984905@s.whatsapp.net", "message_type": "audio", "content": "", "status": "played", "source": "phone", "media_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "media": { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "mime_type": "audio/ogg; codecs=opus", "file_name": "voice.ogg", "file_size": 34567, "download_url": "/v1/instances/84c2e480-.../media/b2c3d4e5-...", "info_url": "/v1/instances/84c2e480-.../media/b2c3d4e5-.../info", "created_at": "2026-03-07T12:00:00Z" }, "quoted_msg_id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890", "quoted_wa_msg_id": "3EB0AAA111BBB222", "queued_at": null, "sent_at": null, "delivered_at": "2026-03-07T12:00:02Z", "read_at": "2026-03-07T12:00:10Z", "played_at": "2026-03-07T12:00:15Z", "deleted_at": null, "failed_at": null, "fail_reason": "", "created_at": "2026-03-07T12:00:00Z", "updated_at": "2026-03-07T12:00:15Z" } ``` **Campos da resposta:** | Campo | Tipo | Descricao | |-------|------|-----------| | `external_id` / `message_id` | string | UUID BiaZap (v4). **Identificador canonico estável** — use este para correlacionar com eventos de webhook (no payload aparece como elemento de `message_ids`). `external_id` e o nome histórico; `message_id` e o alias retornado nesta resposta sincrona. | | `whatsapp_id` | string | ID hex do WhatsApp. Util para cross-reference com logs do WhatsApp mas **pode ser reciclado** entre mensagens — não use como chave primaria | | `instance_id` | string | Instância origem | | `direction` | string | `inbound` ou `outbound` | | `remote_jid` / `chat` | string | JID da conversa (DM: `@s.whatsapp.net`, grupo: `@g.us`) | | `phone` | string | Telefone extraido do chat (vazio em grupos) | | `is_group` | bool | `true` se a conversa e um grupo | | `is_deleted` | bool | `true` se a mensagem foi revogada (status=`deleted`) | | `sender_jid` | string | JID de quem enviou (em grupos, diferente do `chat`; em DMs geralmente igual) | | `message_type` | string | `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `reaction`, `poll`, `template`, `forward`, `live_location` | | `content` | string | **Conteúdo atual** (já reflete edicoes). Para reactions contem o emoji | | `status` | string | `queued`, `sent`, `delivered`, `read`, `played`, `failed`, `deleted` | | `source` | string | `api` (enviado via BiaZap), `external` (enviado por outro aparelho/WhatsApp Web), `phone` (recebido) | | `media_id` | string | UUID da mídia anexa (quando houver) | | `media` | object | **Objeto inline** com metadata completa da mídia (veja abaixo). Presente apenas quando ha mídia. Evita chamada extra | | `quoted_msg_id` | string | UUID BiaZap da mensagem sendo respondida. Vazio se a mensagem citada não estiver no banco do BiaZap (ex: reply antigo) | | `quoted_wa_msg_id` | string | Hex original do WhatsApp da mensagem citada (legado / cross-reference) | | `queued_at` / `sent_at` / `delivered_at` / `read_at` / `played_at` / `failed_at` / `deleted_at` | datetime | Timestamps do ciclo de vida. `played_at` so para view-once/audio PTT | | `fail_reason` | string | Mensagem de erro se `status=failed` | | `created_at` / `updated_at` | datetime | Metadata do registro | **Objeto `media` inline:** | Campo | Tipo | Descricao | |-------|------|-----------| | `id` | string | UUID da mídia | | `mime_type` | string | MIME (ex: `image/jpeg`, `audio/ogg; codecs=opus`) | | `file_name` | string | Nome original (se houver) | | `file_size` | int | Tamanho em bytes | | `download_url` | string | Path para baixar o binario (`GET /v1/instances/{instanceId}/media/{mediaId}`) | | `info_url` | string | Path para o endpoint de metadata (`GET /v1/instances/{instanceId}/media/{mediaId}/info`) | | `created_at` | datetime | Quando foi armazenada | | `expires_at` | datetime | Opcional — TTL da mídia em S3 (`null` se permanente) | > **Dica:** `message_id` e sempre o UUID BiaZap — o mesmo que aparece em `message_ids` nos eventos de webhook. `whatsapp_id` e o hex do protocolo WhatsApp e **pode ser reciclado** após `delete for everyone` (lesson #22 no CLAUDE.md). Use sempre `message_id` como chave primaria no seu banco. **Erros:** | Status | Descricao | |--------|-----------| | 404 | Mensagem não encontrada para está instância | --- ### POST /v1/instances/{instanceId}/chats/{chatId}/action Executa uma ação em um chat. **Auth:** Todos autenticados **Request:** ```json { "action": "pin", "duration": 604800 } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `action` | string | sim | Ação: `archive`, `unarchive`, `pin`, `unpin`, `mute`, `unmute`, `clear` | | `duration` | int64 | não | Duração em segundos (para `mute`) | **Resposta 200:** ```json { "status": "applied", "action": "pin", "chat_id": "554137984905@s.whatsapp.net", "instance_id": "84c2e480-..." } ``` --- ### POST /v1/instances/{instanceId}/mark-read Marca mensagens como lidas. **Auth:** Todos autenticados **Request:** ```json { "chat_jid": "554137984905@s.whatsapp.net", "message_ids": ["3EB0ABC123", "3EB0DEF456"] } ``` Ou para marcar todas: ```json { "chat_jid": "554137984905@s.whatsapp.net", "mark_all": true } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `chat_jid` | string | sim | JID do chat | | `message_ids` | []string | não | IDs das mensagens a marcar | | `mark_all` | bool | não | Marcar todas como lidas | **Resposta 200:** Quando `mark_all` = false (ou omitido): ```json { "instance_id": "84c2e480-...", "marked_read": 2 } ``` Quando `mark_all` = true: ```json { "instance_id": "84c2e480-...", "chat_jid": "554137984905@s.whatsapp.net", "marked_all": true } ``` --- ### POST /v1/instances/{instanceId}/mark-unread Marca uma conversa como não lida. O efeito e sincronizado com todos os dispositivos WhatsApp conectados (aparece o badge azul de "não lido"). **Auth:** Todos autenticados **Request:** ```json { "chat_jid": "554137984905@s.whatsapp.net" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `chat_jid` | string | sim | JID do chat a marcar como não lido | **Resposta 200:** ```json { "instance_id": "84c2e480-...", "chat_jid": "554137984905@s.whatsapp.net", "marked_unread": true } ``` --- ## 11. Contatos ### GET /v1/instances/{instanceId}/contacts Lista contatos da instância. **Auth:** Todos autenticados **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 20 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "jid": "554137984905@s.whatsapp.net", "push_name": "Joao", "business_name": "", "full_name": "Joao Silva", "first_name": "Joao", "profile_pic_url": "https://media.biazap.com/.../avatars/554137984905.jpg" } ], "total": 15, "page": 1, "limit": 20 } ``` > **Nota sobre `profile_pic_url`:** Presente somente quando o avatar do contato já foi cacheado no storage da BiaZap. Contatos sem foto ou com foto ainda não cacheada não incluem este campo. O cache e preenchido automaticamente quando o contato muda a foto (`events.Picture`) ou via lazy load no primeiro acesso ao endpoint `/avatar`. --- ### GET /v1/instances/{instanceId}/contacts/{contactId} Retorna informações de um contato. A resposta vem do **banco do BiaZap** (registro persistente) e opcionalmente e enriquecida com dados ao vivo do WhatsApp (status, avatar atualizado). O endpoint funciona mesmo se a instância estiver desconectada — retorna o último estado conhecido. **Auth:** Todos autenticados **Parametros de URL:** | Parametro | Descricao | |-----------|-----------| | `instanceId` | ID da instância | | `contactId` | Telefone (E.164, com ou sem `+`), phone JID (`554199...@s.whatsapp.net`), ou LID (`2162...@lid`). BR com 9 extra e normalizado automaticamente | **Query params:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `live` | bool | `true` | `false` desabilita o enrichment com chamada ao WhatsApp (retorna so o estado do banco). Use quando quiser resposta rápida e offline-safe | **Resposta 200:** ```json { "id": 42, "jid": "554137984905@s.whatsapp.net", "phone": "554137984905", "push_name": "Joao Silva", "business_name": "", "full_name": "Joao Silva", "first_name": "Joao", "status": "Hey there!", "about": "Busy", "picture_id": "122207890", "picture_url": "https://media.biazap.com/.../avatars/554137984905.jpg", "first_seen_at": "2026-03-01T12:00:00Z", "last_seen_at": "2026-04-17T15:30:00Z", "created_at": "2026-03-01T12:00:00Z", "updated_at": "2026-04-17T15:30:00Z", "identities": [ { "jid": "554137984905@s.whatsapp.net", "type": "phone", "first_seen_at": "2026-03-01T12:00:00Z", "last_seen_at": "2026-04-17T15:30:00Z" }, { "jid": "216251804708983@lid", "type": "lid", "first_seen_at": "2026-03-15T08:00:00Z", "last_seen_at": "2026-04-17T15:30:00Z" } ] } ``` **Campos da resposta:** | Campo | Tipo | Descricao | |-------|------|-----------| | `id` | int | ID interno do registro (não persistente entre ambientes) | | `jid` | string | Phone JID canonico (`@s.whatsapp.net`) | | `phone` | string | Número sem sufixo (E.164 sem `+`) | | `push_name` / `business_name` / `full_name` / `first_name` | string | Nomes do contato (ordem de preferência: push_name > business_name > full_name) | | `status` | string | Status/about ao vivo (apenas com `live=true` e instância conectada) | | `about` | string | About salvo no banco (persistido de eventos `UserAbout`) | | `picture_url` | string | URL do avatar — **sempre controlada pela BiaZap** (S3/R2 público ou endpoint autenticado `/avatar`). Nunca e URL direta do CDN do WhatsApp | | `first_seen_at` / `last_seen_at` | datetime | Primeira e última interacao conhecida | | `identities[]` | array | **Todas as variantes conhecidas** de JID/LID para este contato. `type=phone` ou `type=lid`. Util para consumidores que recebem webhooks com diferentes JIDs e precisam de-duplicar (lesson #5 — WhatsApp não resolve LID→phone ao vivo, so via registry) | > **Nota:** Quando `live=true` e a instância está conectada, o endpoint enriquece a resposta com dados atualizados do WhatsApp (status, nomes, avatar). Quando `live=false` ou a instância está desconectada, retorna so o que o BiaZap persistiu — resposta sempre disponível. > **Nota (politica de proxy — lesson #29):** `picture_url` retorna somente URL controlada pela BiaZap. Se o avatar ainda não foi cacheado pelo worker (baixado através do proxy ISP da instância e armazenado no S3/R2), o campo não vem na resposta. A API **nunca** expoe URL temporaria do CDN do WhatsApp pro frontend — fazer isso delegaria o bypass do proxy ao navegador do cliente. **Erros:** | Status | Descricao | |--------|-----------| | 200 | Contato encontrado OU `identities[]` vazio (ainda sem interacao com esse número). Sempre 200 pra permitir o consumidor guardar o JID mesmo antes da primeira mensagem | --- ### GET /v1/instances/{instanceId}/contacts/{contactId}/avatar Retorna a imagem de avatar do contato diretamente (proxy via S3/R2). Na primeira requisição para um contato sem cache, busca a foto do WhatsApp, armazena no storage, e retorna a imagem. **Auth:** Todos autenticados (JWT ou API Key) **Headers de resposta:** - `Content-Type: image/jpeg` (ou outro tipo conforme a imagem) - `Cache-Control: public, max-age=86400` **Resposta 200:** Imagem binaria (JPEG/PNG). **Resposta 404:** Contato não encontrado ou sem foto de perfil. **Resposta 503:** Storage S3/R2 não configurado. **Comportamento de cache:** - **Primeiro acesso:** busca a foto do WhatsApp, faz upload no S3/R2, e retorna a imagem (lazy cache). - **Acessos seguintes:** serve diretamente do S3/R2 (rápido, sem depender do WhatsApp). - **Mudanca de foto:** quando o WhatsApp notifica via `events.Picture`, o cache e atualizado automaticamente em background. - **Remoção de foto:** quando o contato remove a foto, o cache e limpo e o endpoint passa a retornar 404. > **Dica para clientes:** Use `profile_pic_url` retornado nos endpoints `GET /chats`, `GET /contacts`, ou `GET /contacts/{id}` como `src` de tags ``. Estas URLs nunca expiram (diferente das URLs do CDN do WhatsApp). Se `S3_PUBLIC_URL` estiver configurado no servidor, a URL e pública e pode ser usada diretamente; caso contrario, a URL aponta para o endpoint proxy `/avatar` que requer autenticação. --- ### POST /v1/playground/generate-payload Gera um body de exemplo via OpenAI para o endpoint informado. Usado pelo simulador da Console (botao "🎲 Outro exemplo"). Resposta cacheada por 7 dias (cache hit não gasta budget). Modelos default: `gpt-5.4-nano` (texto) + `gpt-image-1-mini` (imagem). **Auth:** Todos autenticados (JWT ou API Key) **Request:** ```json { "endpoint": "messages/text", "context": "casual" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `endpoint` | string | sim | `"messages/text"`, `"messages/image"`, `"messages/audio"`, `"messages/document"`, `"messages/sticker"`, `"messages/video"`, `"messages/poll"`, `"messages/template"`. Outros valores retornam `400 PLAYGROUND_UNSUPPORTED_KIND`. | | `context` | string | não | `"casual"` (default), `"business"`, ou `"friendly"`. Influencia o tom. | **Resposta 200:** ```json { "body": {"text": "Tudo certo, me confirma quando puder?"}, "generation_id": "gen_a1b2c3d4e5f6...", "cached": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `body` | object | Partial body — o caller faz `merge` com a template existente. Para `messages/image`/`video`/`sticker`, contem `media_url` apontando para R2/S3 controlado pela BiaZap (nunca URL OpenAI/Meta). | | `generation_id` | string | ID curto para correlação em logs (cache hits e misses compartilham o mesmo ID quando o prompt e identico). | | `cached` | bool | `true` quando o body veio do cache (sem call ao OpenAI). | **Erros:** - `503 PLAYGROUND_DISABLED` — OPENAI_API_KEY não configurado neste servidor - `429 PLAYGROUND_BUDGET_EXCEEDED` — limite diário de gasto atingido (default $10/dia) - `429 PLAYGROUND_RATE_LIMITED` — rate limit per-user atingido (30/min ou 200/dia) - `400 PLAYGROUND_UNSUPPORTED_KIND` — endpoint não reconhecido > **Nota de seguranca:** quando `endpoint` envolve imagem, a BiaZap baixa a imagem do OpenAI, faz upload para o storage controlado (S3/R2 público ou endpoint autenticado), e devolve apenas a URL BiaZap. Nunca expomos URL do OpenAI direto para o consumidor — alinha com a disciplina de proxy/CDN (lesson #29). --- ### GET /v1/instances/{instanceId}/contacts/{contactId}/mutuality Retorna se `phone` (URL param `contactId`) tem essa instância salva na agenda do WhatsApp dele — i.e. são contatos mutuos. Resposta cacheada por 6h via `contact_mutuality_cache` (tenant). Cache miss dispara um `client.IsOnWhatsApp` (uSync) através do worker. **Auth:** Todos autenticados (JWT ou API Key) **Resposta 200:** ```json { "is_mutual": true, "checked_at": "2026-05-06T15:00:00Z", "phone": "5511999999999" } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `is_mutual` | bool | `true` quando o servidor da Meta confirma que o número tem essa instância na agenda | | `checked_at` | RFC3339 | Quando o resultado foi obtido (cache hit OU uSync). Use para mostrar "verificado ha N min" | | `phone` | string | Número normalizado (sem `+`, sem sufixo `@`) | **Resposta 503:** Instância não conectada ou worker indisponível. **Notas:** - O `contactId` aceita formato puro (`5511999999999`), com `+` (`+5511999999999`), ou JID completo (`5511999999999@s.whatsapp.net`). Tudo e normalizado para o número limpo. - Cache hit dentro de 6h retorna sem roundtrip. - Cache stale + uSync com falha retorna o último valor conhecido (graceful fallback). - Apenas o `info.IsIn` flag do whatsmeow (a contraparte do `is_in_address_book` da Meta). --- ### Temperatura de Contato (Anti-Spam) Score de engajamento 0-100 por contato, usado internamente para limitar mensagens outbound consecutivas sem reply inbound (regra de temperatura do anti-spam guard). O score deriva de histórico inbound + recencia + penalty por outbound consecutivo + boost de mutualidade. **Tiers** (curva fixa): - `cold` (0-19) — `max_consecutive=2` - `warm` (20-39) — `max_consecutive=3` - `engaged` (40-59) — `max_consecutive=4` - `hot` (60-79) — `max_consecutive=5` - `very_hot` (80-100) — `max_consecutive=7` `max_consecutive` e o limite de envios outbound seguidos sem reply do contato; quando atingido, o próximo envio retorna `409 BLOCKED_TEMPERATURE_LIMIT`. Bypass via header `X-Force-Send: true` (audit-logged). --- ### GET /v1/instances/{instanceId}/contacts/{contactId}/temperature Retorna o score de temperatura corrente para um contato. Bootstraps a linha do `contact_temperatures` a partir do histórico em `messages` na primeira chamada (lazy). **Auth:** Todos autenticados (JWT ou API Key) **Resposta 200:** ```json { "phone": "554199999999", "remote_jid": "554199999999@s.whatsapp.net", "score": 65, "tier": "hot", "max_consecutive": 5, "inbound_count_total": 8, "outbound_consecutive": 1, "last_inbound_at": "2026-05-07T13:42:11Z", "last_outbound_at": "2026-05-07T14:01:00Z", "bootstrap_source": "history", "bootstrapped_at": "2026-04-30T08:15:22Z" } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número normalizado | | `remote_jid` | string | JID canonico `@s.whatsapp.net` | | `score` | int | 0-100, computado em runtime a partir dos contadores persistidos | | `tier` | string | `cold` \| `warm` \| `engaged` \| `hot` \| `very_hot` | | `max_consecutive` | int | Limite de outbound consecutivo derivado do tier | | `inbound_count_total` | int | Total de inbounds na vida do contato | | `outbound_consecutive` | int | Outbounds desde o último inbound | | `last_inbound_at` | RFC3339 \| null | Último inbound recebido | | `last_outbound_at` | RFC3339 \| null | Último outbound enviado | | `bootstrap_source` | string | `history` \| `mutuality` \| `cold` \| `manual` — evidência usada no bootstrap | | `bootstrapped_at` | RFC3339 \| null | Quando a linha foi seeded | **Notas:** - `contactId` aceita `5511999999999`, `+5511999999999`, ou JID completo. Normalizado. - A mutualidade NÃO e consultada nesta leitura (evita RPC). O score-time mutuality boost so atua via send-path Check. - Bootstrap pode levar centenas de ms na primeira chamada para um contato com histórico denso (4 queries indexadas em `messages`); leituras subsequentes são baratas. --- ### GET /v1/instances/{instanceId}/contacts/temperature Lista contatos paginada, filtravel por faixa de score. **Auth:** Todos autenticados **Query params:** | Param | Default | Descricao | |-------|--------:|-----------| | `min_score` | 0 | Score mínimo (0-100) | | `max_score` | 100 | Score máximo (0-100) | | `page` | 1 | Página (1-based) | | `limit` | 50 | Tamanho da página (max 200) | **Resposta 200:** ```json { "items": [ { "phone": "554199999999", "remote_jid": "554199999999@s.whatsapp.net", "score": 65, "tier": "hot", "max_consecutive": 5, "inbound_count_total": 8, "outbound_consecutive": 1, "last_inbound_at": "2026-05-07T13:42:11Z", "last_outbound_at": "2026-05-07T14:01:00Z", "bootstrap_source": "history", "bootstrapped_at": "2026-04-30T08:15:22Z" } ], "total": 1234, "page": 1, "limit": 50 } ``` **Notas:** - Score e calculado em-app a partir de cada linha — para tenants com >100k contatos, considere o filtro do `min_score`/`max_score` para reduzir o conjunto antes de paginar. - Ordenacao primaria: `last_inbound_at DESC` (contatos mais recentes primeiro). --- ### POST /v1/admin/instances/{instanceId}/temperature/backfill *(superadmin)* Enfileira um task Asynq que bootstraps `contact_temperatures` para todos os contatos conhecidos da `messages` table do instance. Idempotente — re-rodar e no-op para linhas já bootstrapped. **Auth:** Superadmin **Resposta 202:** ```json { "status": "queued", "task_id": "abc123-..." } ``` **Notas:** - Operação caminha a tabela `messages` inteira para o instance — pode levar segundos a minutos em tenants busy. - Sem endpoint de progresso em v1; verifique o crescimento de linhas em `contact_temperatures` ou o estado do task via `/v1/admin/queue`. - Padrão util: dispare logo após uma pareation + `history.sync` para popular temperatura de todos os contatos conhecidos em batch (em vez de esperar bootstrap lazy no primeiro outbound a cada um). --- ### GET /v1/instances/{instanceId}/profile/avatar Retorna a foto de perfil da própria instância conectada. O fluxo é identico ao avatar de contato: a BiaZap busca a imagem via worker/whatsmeow, armazena no S3/R2 e expõe apenas URL/local proxy controlado pela plataforma. **Auth:** Todos autenticados (JWT ou API Key) **Headers de resposta:** - `Content-Type: image/jpeg` (ou outro tipo conforme a imagem) - `Cache-Control: public, max-age=86400` **Resposta 200:** Imagem binaria (JPEG/PNG). **Resposta 404:** Instância sem foto de perfil. **Resposta 503:** Storage S3/R2 não configurado. --- ### POST /v1/instances/{instanceId}/contacts/{contactId}/presence/subscribe Inicia ou renova o monitoramento de presença de um contato 1:1. **Auth:** Todos autenticados > Aceita telefone ou JID. Números BR com 9 extra são normalizados automaticamente. Não aceita grupos (`@g.us`). > Use este endpoint ao abrir a tela do chat. Ele renova uma lease transiente de monitoramento por **300s**. Chamadas repetidas durante a lease são idempotentes e apenas renovam o TTL local. **Resposta 200:** ```json { "instance_id": "84c2e480-...", "contact_jid": "551199999999@s.whatsapp.net", "subscribed": true, "monitoring_ttl_seconds": 300 } ``` --- ### GET /v1/instances/{instanceId}/contacts/{contactId}/presence Retorna o snapshot transiente de presença/typing para um contato 1:1. **Auth:** Todos autenticados > O snapshot e mantido em Redis, não em MySQL. `monitoring_active=true` indica que a lease de monitoramento ainda está vigente. O último snapshot conhecido fica em cache por até **24h**. `typing_state` expira após **10s** sem novos eventos. **Resposta 200:** ```json { "contact_jid": "551199999999@s.whatsapp.net", "monitoring_active": true, "online": true, "last_seen": 1741360000, "typing_state": "composing", "typing_chat": "551199999999@s.whatsapp.net", "is_group": false, "observed_at": "2026-03-25T15:04:05Z" } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `contact_jid` | string | JID canonico do contato | | `monitoring_active` | bool | Lease de monitoramento ainda ativa | | `online` | bool | Último status online/offline conhecido | | `last_seen` | int64 | Último visto (unix, pode ser `0` se oculto) | | `typing_state` | string | `composing`, `paused` ou `recording` enquanto estiver fresco | | `typing_chat` | string | Chat onde o typing foi observado | | `is_group` | bool | Sempre omitido/falso para snapshot de contato 1:1 | | `observed_at` | string(datetime) | Quando o último evento relevante foi observado | --- ### POST /v1/instances/{instanceId}/contacts/check Verifica se números de telefone possuem WhatsApp. **Auth:** Todos autenticados **Request:** ```json { "numbers": ["554137984905", "5541988887777"] } ``` **Resposta 200:** ```json { "instance_id": "84c2e480-...", "results": [ {"query": "554137984905", "jid": "554137984905@s.whatsapp.net", "is_in": true, "verified_name": ""}, {"query": "5541988887777", "jid": "", "is_in": false, "verified_name": ""} ] } ``` --- ### POST /v1/instances/{instanceId}/contacts/block Bloqueia um contato. **Auth:** Todos autenticados **Request:** ```json { "contact_id": "554137984905" } ``` > Aceita telefone ou JID. Números BR com 9 extra são normalizados automaticamente. **Resposta 200:** ```json { "instance_id": "84c2e480-...", "contact_id": "554137984905@s.whatsapp.net", "blocked": true } ``` --- ### POST /v1/instances/{instanceId}/contacts/unblock Desbloqueia um contato. **Auth:** Todos autenticados **Request:** ```json { "contact_id": "554137984905" } ``` **Resposta 200:** ```json { "instance_id": "84c2e480-...", "contact_id": "554137984905@s.whatsapp.net", "blocked": false } ``` --- ## 12. Presença e Perfil ### POST /v1/instances/{instanceId}/presence Define a presença (status online) da instância. **Auth:** Todos autenticados **Request:** ```json { "presence": "composing", "chat_jid": "554137984905@s.whatsapp.net" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `presence` | string | sim | `available`, `unavailable`, `composing`, `recording`, `paused` | | `chat_jid` | string | não | JID do chat (para presença por chat, ex: "digitando...") | **Valores de presença:** | Valor | Efeito no WhatsApp | |-------|---------------------| | `available` | Mostra "online" | | `unavailable` | Remove "online" | | `composing` | Mostra "digitando..." (requer `chat_jid`) | | `recording` | Mostra "gravando audio..." (requer `chat_jid`) | | `paused` | Remove "digitando..." (requer `chat_jid`) | **Resposta 200:** ```json { "instance_id": "84c2e480-...", "presence": "composing", "chat_jid": "554137984905@s.whatsapp.net" } ``` --- ### GET /v1/instances/{instanceId}/profile Retorna o perfil da instância (do número conectado). **Auth:** Todos autenticados **Query Parameters:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `include` | string | `privacy` para incluir configurações de privacidade | **Resposta 200:** ```json { "instance_id": "84c2e480-...", "jid": "554137984905@s.whatsapp.net", "push_name": "Empresa LTDA", "status": "Atendimento 24h", "picture_url": "/v1/instances/84c2e480-.../profile/avatar" } ``` > **Nota:** `picture_url`, quando presente, sempre aponta para URL controlada pela BiaZap (S3 público ou `/profile/avatar`). Não ha fallback para URL temporaria do CDN do WhatsApp. --- ### PATCH /v1/instances/{instanceId}/profile Atualiza o perfil da instância no WhatsApp. **Auth:** Todos autenticados #### Opcao 1: JSON ```json { "name": "Novo Nome", "status": "Novo status" } ``` #### Opcao 2: Multipart (para enviar foto) ``` Content-Type: multipart/form-data name=Novo Nome status=Novo status picture= ``` **Limite:** `picture` tem **10 MB** de tamanho máximo (request multipart inteiro). Imagens `image/*` (JPEG/PNG/WebP) são recomendadas; o WhatsApp gera thumbnails internamente. | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `name` | string | não | Novo nome (push_name) | | `status` | string | não | Novo recado/status | | `picture` | file/base64 | não | Nova foto de perfil (max 10 MB) | **Resposta 200:** ```json { "instance_id": "84c2e480-...", "updated": true, "fields": ["name", "status"] } ``` --- ## 13. Grupos ### POST /v1/instances/{instanceId}/groups Cria um novo grupo no WhatsApp. **Auth:** Todos autenticados **Request:** ```json { "name": "Equipe Vendas", "participants": ["554137984905", "5541988887777"] } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `name` | string | sim | Nome do grupo (max 25 caracteres) | | `participants` | []string | sim | Números ou JIDs dos participantes | > Números BR com 9 extra são normalizados automaticamente. **Resposta 201:** Objeto do grupo criado. --- ### GET /v1/instances/{instanceId}/groups Lista todos os grupos da instância. **Auth:** Todos autenticados **Resposta 200:** ```json { "data": [ { "jid": "120363012345678901@g.us", "name": "Equipe Vendas", "participants_count": 5 } ], "total": 3 } ``` --- ### GET /v1/instances/{instanceId}/groups/{groupId} Retorna informações detalhadas de um grupo. **Auth:** Todos autenticados **Resposta 200:** ```json { "jid": "120363012345678901@g.us", "name": "Equipe Vendas", "description": "Grupo de vendas da equipe", "participants": [ { "jid": "554137984905@s.whatsapp.net", "is_admin": true }, { "jid": "5541988887777@s.whatsapp.net", "is_admin": false } ], "is_announce": false, "is_locked": false } ``` --- ### PATCH /v1/instances/{instanceId}/groups/{groupId} Atualiza informações do grupo. **Auth:** Todos autenticados (deve ser admin do grupo) **Request:** ```json { "name": "Novo Nome do Grupo", "description": "Nova descricao", "picture": "" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `name` | string | não | Novo nome (max 25 chars) | | `description` | string | não | Nova descricao | | `picture` | string | não | Nova foto (base64 JPEG) | **Resposta 200:** ```json { "updated": true, "name": "Novo Nome do Grupo" } ``` --- ### PATCH /v1/instances/{instanceId}/groups/{groupId}/settings Altera configurações do grupo. **Auth:** Todos autenticados (deve ser admin do grupo) **Request:** ```json { "announce": true, "locked": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `announce` | bool | `true` = so admins enviam mensagens | | `locked` | bool | `true` = so admins editam info do grupo | **Resposta 200:** ```json { "announce": true, "locked": false } ``` --- ### POST /v1/instances/{instanceId}/groups/{groupId}/participants/add Adiciona participantes ao grupo. **Auth:** Todos autenticados **Request:** ```json { "participants": ["554137984905", "5541977776666"] } ``` **Resposta 200:** ```json { "results": [ {"jid": "554137984905@s.whatsapp.net", "status": "added"} ] } ``` --- ### POST /v1/instances/{instanceId}/groups/{groupId}/participants/remove Remove participantes do grupo. **Auth:** Todos autenticados **Request:** ```json { "participants": ["554137984905"] } ``` **Resposta 200:** ```json { "results": [ {"jid": "554137984905@s.whatsapp.net", "status": "removed"} ] } ``` --- ### POST /v1/instances/{instanceId}/groups/{groupId}/participants/promote Promove participantes a admin do grupo. **Auth:** Todos autenticados **Request:** ```json { "participants": ["554137984905"] } ``` **Resposta 200:** ```json { "results": [ {"jid": "554137984905@s.whatsapp.net", "status": "promoted"} ] } ``` --- ### POST /v1/instances/{instanceId}/groups/{groupId}/participants/demote Remove admin de participantes. **Auth:** Todos autenticados **Request:** ```json { "participants": ["554137984905"] } ``` **Resposta 200:** ```json { "results": [ {"jid": "554137984905@s.whatsapp.net", "status": "demoted"} ] } ``` --- ### GET /v1/instances/{instanceId}/groups/{groupId}/invite-link Retorna o link de convite do grupo. **Auth:** Todos autenticados **Resposta 200:** ```json { "invite_link": "https://chat.whatsapp.com/AbCdEfGhIjKl" } ``` --- ### POST /v1/instances/{instanceId}/groups/{groupId}/invite-link/revoke Revoga o link de convite atual e gera um novo. **Auth:** Todos autenticados **Resposta 200:** ```json { "invite_link": "https://chat.whatsapp.com/NoVoLiNk1234" } ``` --- ### POST /v1/instances/{instanceId}/groups/join Entra em um grupo usando link de convite. **Auth:** Todos autenticados **Request:** ```json { "invite_link": "https://chat.whatsapp.com/AbCdEfGhIjKl" } ``` **Resposta 200:** ```json { "group_jid": "120363012345678901@g.us" } ``` --- ### POST /v1/instances/{instanceId}/groups/{groupId}/leave Sai de um grupo. **Auth:** Todos autenticados **Resposta 200:** ```json { "left": true } ``` --- ## 14. Fila de Mensagens A fila de processamento e baseada em Redis (Asynq). Cada instância tem sua própria fila. ### GET /v1/instances/{instanceId}/queue Retorna o status da fila da instância. **Auth:** Owner, Admin **Resposta 200:** ```json { "queue_pending": 5, "queue_active": 1, "queue_scheduled": 2, "queue_retry": 0, "queue_archived": 100, "queue_completed": 500 } ``` --- ### POST /v1/instances/{instanceId}/queue/pause Pausa o processamento da fila. Mensagens continuam sendo aceitas mas não são enviadas. **Auth:** Owner, Admin **Resposta 200:** ```json { "status": "paused", "instance_id": "84c2e480-..." } ``` --- ### POST /v1/instances/{instanceId}/queue/resume Retoma o processamento da fila. **Auth:** Owner, Admin **Resposta 200:** ```json { "status": "resumed", "instance_id": "84c2e480-..." } ``` --- ### DELETE /v1/instances/{instanceId}/queue Limpa todas as mensagens pendentes da fila. **Auth:** Owner, Admin **Resposta 200:** ```json { "status": "cleared", "instance_id": "84c2e480-..." } ``` --- ## 15. Webhooks Webhooks permitem receber eventos em tempo real via HTTP POST no seu servidor. **Semantica de entrega:** pelo menos uma vez (*at-least-once*). Retries do worker, falhas de rede ou retry manual podem gerar o **mesmo** `event_id` mais de uma vez. O endpoint do cliente deve ser **idempotente** e deduplicar por `event_id` (ou pelo par `webhook_id` + `event_id`). **Ordem em relação ao banco (tenant):** para `message.delivered`, `message.read`, `message.played`, `message.deleted` e `message.edited`, o BiaZap aplica primeiro o `UPDATE` na tabela `messages` do tenant e em seguida enfileira/dispara webhooks e streams em tempo real — o estado persistido não fica atrás do envio do evento HTTP. ### POST /v1/webhooks Cria um novo webhook. **Auth:** Owner, Admin **Request:** ```json { "url": "https://meuservidor.com/webhook", "instance_id": "84c2e480-...", "events": "message.received,message.sent", "secret": "whsec_minha_chave_compartilhada" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `url` | string | sim | URL do seu endpoint (`https://` por padrão) | | `instance_id` | string | não | ID da instância para filtrar. Vazio/omitido = **global** (recebe de todas as instâncias da empresa) | | `events` | string | sim | Eventos filtrados (separados por virgula). `*` = todos | | `secret` | string | não | Secret customizado. Se omitido, a API gera um automaticamente | > **Escopo do webhook — por instância vs global:** > - **Por instância:** defina `instance_id` para receber eventos apenas daquela instância. Recomendado quando cada instância envia para um backend diferente. > - **Global:** omita `instance_id` (ou envie vazio) para receber eventos de **todas** as instâncias da empresa no mesmo endpoint. > - **Cuidado com duplicacao:** se você criar um webhook global E um por instância apontando para o mesmo URL, os eventos daquela instância serao entregues **duas vezes** (uma por cada webhook). Prefira um ou outro. **Eventos disponíveis:** | Evento | Descricao | Persistido | |--------|-----------|:----------:| | `message.received` | Mensagem recebida | Sim (tabela `messages`) | | `message.sent` | Mensagem enviada com sucesso | Sim (tabela `messages`) | | `message.delivered` | Mensagem entregue (double check) | Sim (atualiza `delivered_at`) | | `message.read` | Mensagem lida (blue check) | Sim (atualiza `read_at`) | | `message.played` | Mídia visualizar-uma-vez aberta | Sim (atualiza `played_at`) | | `message.deleted` | Mensagem apagada/revogada | Sim (atualiza `status=deleted`) | | `message.edited` | Mensagem editada | Sim (atualiza `content`) | | `message.undecryptable` | Mensagem recebida mas não pode ser decriptada | Não cria linha em `messages`; salvo como evento operacional | | `message.decrypt_recovered` | Mensagem antes indecriptável recuperada após retry | Não cria linha extra em `messages`; salvo como evento operacional | | `message.decrypt_lost` | Mensagem indecriptável expirada sem recuperação | Não cria linha em `messages`; salvo como evento operacional | | `message.starred` | Mensagem marcada/desmarcada com estrela | Não | | `message.deleted_for_me` | Mensagem apagada localmente (apenas para mim) | Não | | `call.received` | Chamada recebida | Sim (tabela `call_logs`) | | `call.accepted` | Chamada aceita pela parte remota | Sim (tabela `call_logs`) | | `call.rejected` | Chamada rejeitada pela parte remota (caller-side) | Sim (tabela `call_logs`) | | `call.declined_by_me` | Você (instância) recusou uma chamada de entrada (UI do telefone ou auto-rejeição) | Não cria nova linha — atualiza `call_logs.end_reason` via `call.ended` | | `call.ended` | Chamada finalizada (qualquer motivo: timeout, hangup, rejeição) | Sim (tabela `call_logs`) | | `call.group_offer` | Chamada em grupo recebida | Sim (tabela `call_logs`) | | `connection.update` | Mudanca de status da conexão | Sim (tabela `instances`) | | `presence.update` | Presença ou digitacao de contato | Não em MySQL; snapshot opcional em Redis quando o contato está monitorado | | `group.update` | Mudanca em grupo | Sim (tabela `group_events`) | | `group.joined` | Instância foi adicionada a um grupo | Sim (tabela `group_events`) | | `contact.update` | Mudanca de contato (foto, nome, etc.) | Sim (tabela `contact_events`) | | `contact.identity_changed` | Contato trocou dispositivo principal | Sim (tabela `contact_events`) | | `contact.sync` | Contato sincronizado do dispositivo | Sim (tabela `contact_events`) | | `instance.banned` | Instância recebeu ban do WhatsApp | Sim (atualiza ban_expiry e ban_reason na instância) | | `instance.offline` | Instância desconectada ha 15min ou mais (transicao única, não re-fira) | Sim (campo `offline_alert_level=warning` na instância) | | `instance.critical_offline` | Instância desconectada ha 6h ou mais; também dispara email para o owner + superadmins | Sim (campo `offline_alert_level=critical` na instância) | | `instance.recovered` | Instância voltou a conectar após ter ficado offline/critical_offline | Sim (limpa `offline_alert_level`) | | `blocklist.update` | Lista de bloqueados alterada | Não | | `chat.pin` | Chat fixado/desfixado | Não | | `chat.archive` | Chat arquivado/desarquivado | Não | | `chat.mute` | Chat silenciado/dessilenciado | Não | | `chat.read_state` | Chat marcado como lido/não lido | Não | | `chat.clear` | Chat limpo | Não | | `chat.delete` | Chat apagado | Não | | `privacy.update` | Configurações de privacidade alteradas | Não | | `history.sync` | Sincronização de histórico recebida | Não | | `sync.preview` | Preview de sincronização offline | Não | | `media.retry_result` | Resultado de retry de mídia | Não | | `label.edit` | Etiqueta criada/editada/removida | Não | | `label.chat_association` | Chat etiquetado/desetiquetado | Não | | `label.message_association` | Mensagem etiquetada/desetiquetada | Não | | `*` | Todos os eventos | — | **Resposta 201:** ```json { "id": 1, "url": "https://meuservidor.com/webhook", "secret": "a1b2c3d4e5f6...64_hex_chars", "events": "message.received,message.sent", "active": true } ``` > O `secret` e gerado automaticamente e usado para assinar os payloads via HMAC-SHA256. > A URL e validada no cadastro/edicao e revalidada no momento da entrega; destinos internos ou resolvidos para rede privada são rejeitados. > O `secret` aparece **somente** na resposta de criação. `GET`, `LIST` e `PATCH` nunca o reexibem. > A entrega e `https` only por padrão. Em runtime você pode afrouxar isso com `WEBHOOK_ALLOW_INSECURE_HTTP=true` ou restringir dominios com `WEBHOOK_ALLOWED_DOMAINS` / `WEBHOOK_BLOCKED_DOMAINS`. --- ### GET /v1/webhooks Lista todos os webhooks da empresa. **Auth:** Owner, Admin **Resposta 200:** ```json [ { "id": 1, "url": "https://meuservidor.com/webhook", "events": "message.received,message.sent", "active": true, "created_at": "2026-03-07T10:00:00Z" } ] ``` --- ### GET /v1/webhooks/{webhookId} Retorna detalhes de um webhook. **Auth:** Owner, Admin **Resposta 200:** Objeto webhook **sem** o campo `secret`. --- ### PATCH /v1/webhooks/{webhookId} Atualiza um webhook. **Auth:** Owner, Admin **Request:** ```json { "url": "https://novo-servidor.com/webhook", "events": "*", "active": false, "secret": "whsec_rotacionado_manual" } ``` Todos os campos são opcionais. **Resposta 200:** Objeto webhook atualizado **sem** o campo `secret`. --- ### DELETE /v1/webhooks/{webhookId} Remove um webhook. **Auth:** Owner, Admin **Resposta 204:** Sem corpo. --- ### GET /v1/webhooks/{webhookId}/logs Lista logs de entrega do webhook. **Retencao e privacidade:** registros de entrega com sucesso são removidos após **7 dias**; falhas permanecem **14 dias** (tarefa periodica `webhook:cleanup_logs`). Em cada execução, linhas com mais de **48 horas** passam por **redacao**: o campo `payload` da API vira um JSON enxuto com `event_type`, `event_id` e `redacted: true`; copia completa do corpo enviado ao cliente e apagada no armazenamento interno; respostas HTTP muito longas são truncadas. Novas linhas já gravam em `payload` apenas metadados (tipo, id, tamanho do JSON). O retry manual depende dessa copia original: quando `payload_raw` já tiver sido limpo, o reenvio não e mais permitido. **Auth:** Owner, Admin **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 50 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "id": 1, "webhook_id": 1, "event_type": "message.received", "payload": "{...}", "status_code": 200, "response": "OK", "success": true, "attempt": 1, "created_at": "2026-03-07T12:00:00Z" } ], "total": 25, "page": 1, "limit": 50 } ``` --- ### POST /v1/webhooks/{webhookId}/logs/{logId}/retry Reenvia um evento que falhou. **Auth:** Owner, Admin **Regras:** so aceita logs com `success=false` e com `payload_raw` ainda preservado. Logs já entregues com sucesso ou já redatados retornam `409 Conflict`. **Resposta 202:** ```json { "status": "retrying", "event_id": "evt_xxxx" } ``` --- ### GET /v1/webhooks/stuck Lista entregas webhook falhadas que precisam de atenção, agregadas por `event_id` (uma linha por evento, com a contagem total de tentativas e a classe do erro). **Auth:** Owner, Admin **Escopo:** somente webhooks da company autenticada. **Query params (todos opcionais):** | Param | Tipo | Default | Descricao | |-------|------|---------|-----------| | `webhook_id` | int | — | Filtra por webhook especifico | | `instance_id` | string | — | Estreita para webhooks dessa instância OU webhooks globais (sem `instance_id`, que disparam para qualquer instância). Usado pela aba Webhooks da página de detalhe da instância. | | `event_type` | string | — | Filtra por tipo de evento (ex: `message.received`) | | `class` | string | — | Filtra por classe do erro: `permanent_4xx`, `transient_5xx`, `rate_limit`, `network`, `unknown` | | `since` | RFC3339 | `now-24h` | Cutoff inferior | | `page` | int | 1 | Página | | `limit` | int | 50 | Itens por página (max 200) | **Resposta 200:** ```json { "data": [ { "log_id": 123, "webhook_id": 7, "webhook_url": "https://app.exemplo.com/webhook", "event_id": "evt-abc-123", "event_type": "message.received", "status_code": 503, "response": "service unavailable", "attempts": 25, "error_class": "transient_5xx", "is_hard_failed": false, "has_payload_raw": true, "created_at": "2026-04-08T15:30:45Z" } ], "total": 12, "page": 1, "limit": 50 } ``` > **`is_hard_failed=true`** indica que o evento bateu em `SkipRetry` por ser `permanent_4xx` após 3 tentativas — provavelmente um bug de configuração (URL errada, secret expirado, parser quebrado). > **`has_payload_raw=false`** indica que o `payload_raw` foi redatado ou nunca foi salvo, e não da pra reenviar via API. --- ### POST /v1/webhooks/retry-bulk Re-enfileira em batch um conjunto de entregas falhadas. Aceita dois modos: **Modo 1 — explicito por log IDs:** ```json { "log_ids": [123, 124, 125] } ``` **Modo 2 — por filtro:** ```json { "webhook_id": 7, "event_type": "message.received", "class": "transient_5xx", "since": "2026-04-07T00:00:00Z" } ``` **Auth:** Owner, Admin **Escopo:** somente webhooks da company autenticada (logs de outros tenants são silenciosamente pulados). **Limites:** máximo de 500 retries por chamada. Eventos com `payload_raw` vazio são pulados. Re-enfileiramento e deduplicado por `event_id`. **Resposta 202:** ```json { "requeued": 7, "skipped": 1, "errors": ["log 199: payload unavailable"] } ``` --- ### Estrategia de retry e classificação de erros Quando uma entrega falha, o BiaZap **classifica** o erro e aplica a estrategia de retry adequada: | Classe | Códigos | Estrategia | |--------|---------|-----------| | `permanent_4xx` | 400, 401, 403, 404, 410, 422 | **Hard-fail após 3 tentativas** — vai pra DLQ. Quase sempre indica URL errada, secret expirado ou bug no parser do consumer. Aparece como "Eventos travados" na UI com badge vermelho. | | `rate_limit` | 408, 429 | Retry pelo schedule completo. | | `transient_5xx` | 500-599 | Retry pelo schedule completo. | | `network` | sem status (DNS, refused, timeout) | Retry pelo schedule completo. Caso típico: consumer reiniciando. | | `unknown` | qualquer outro | Retry pelo schedule completo. | **Schedule de retry (default `WEBHOOK_MAX_RETRY=30`, configuravel por env):** ``` n=1 → 5s n=2 → 15s n=3 → 30s n=4 → 1min n=5 → 2min n=6 → 5min n=7 → 10min n=8 → 15min n=9 → 30min n>=10 → 1h (capped) ``` Janela total: ~22 horas. Cobre restart de serviço, manutenção planejada, picos de carga e blips de infraestrutura sem perder eventos. **Wake-up retry on success:** quando uma entrega chega com sucesso a um webhook, o BiaZap automaticamente re-enfileira **todos** os eventos falhados (excluindo `permanent_4xx`) desse mesmo webhook nas últimas 24h. Isso acelera a recuperacao quando o consumer volta de um restart — você não precisa esperar o backoff exponencial drainar. Um Redis lock por webhook (60s TTL) evita thundering herd. **Persistencia de `payload_raw`:** entregas falhadas mantem o payload original não redatado pelo periodo de retencao completo (14 dias), de modo que retry manual via UI ou via `POST /v1/webhooks/retry-bulk` continue funcionando para eventos antigos. --- ### Verificação de assinatura HMAC Cada requisição webhook inclui `X-BiaZap-Timestamp` e `X-BiaZap-Signature`. A assinatura HMAC-SHA256 cobre a string `timestamp + "." + raw_body`, usando o `secret` do webhook. **Validação recomendada no receiver:** - rejeite timestamps com skew maior que 5 minutos - use comparacao constant-time (`hmac.compare_digest`, `crypto.timingSafeEqual`, `subtle.ConstantTimeCompare`) - deduplicate por `event_id` porque a entrega e `at-least-once` **Headers enviados pelo BiaZap:** ``` Content-Type: application/json User-Agent: BiaZap-Webhook/1.0 X-BiaZap-Timestamp: 1711035600 X-BiaZap-Signature: sha256= ``` **Validação (exemplo em Python):** ```python import hmac import hashlib import time def verify_signature(payload_body, secret, timestamp, signature_header): now = int(time.time()) ts = int(timestamp) if abs(now - ts) > 300: return False signed = f"{timestamp}.".encode() + payload_body expected = "sha256=" + hmac.new( secret.encode(), signed, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature_header) ``` **Validação (exemplo em Node.js):** ```javascript const crypto = require('crypto'); function verifySignature(payloadBody, secret, timestamp, signatureHeader) { const now = Math.floor(Date.now() / 1000); if (Math.abs(now - Number(timestamp)) > 300) { return false; } const signed = Buffer.concat([ Buffer.from(`${timestamp}.`), Buffer.isBuffer(payloadBody) ? payloadBody : Buffer.from(payloadBody) ]); const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(signed) .digest('hex'); if (expected.length !== signatureHeader.length) { return false; } return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signatureHeader) ); } ``` --- ### Payloads dos eventos Todos os eventos seguem a estrutura base: ```json { "event_id": "550e8400-e29b-41d4-a716-446655440000", "type": "message.received", "instance_id": "84c2e480-...", "timestamp": "2026-03-07T15:30:45.123Z", "data": { ... } } ``` | Campo do envelope | Tipo | Observacoes | |-------------------|------|-------------| | `event_id` | string (UUID v4) | Identificador único da entrega. Use para deduplicacao idempotente no receiver. | | `type` | string | Nome do evento (`message.received`, `connection.update`, etc.). Este **e** o nome do campo — não e `event`. | | `instance_id` | string (UUID) | A instância que originou o evento. | | `timestamp` | string (RFC3339) | Quando o BiaZap registrou o evento (não necessariamente quando o WhatsApp gerou). | | `data` | object | Payload especifico do evento. Shape depende de `type` — veja os blocos abaixo. | > **Troubleshooting** — se o seu consumer responde `400 Bad Request: "missing event fields"` ou `"malformed envelope"`, o parser do receiver está esperando uma chave de envelope com nome diferente do que o BiaZap envia (tipicamente `event` em vez de `type`). A correção e no consumer: leia `body.type` (não `body.event`). Cheque também se o consumer usa `express.raw()` (ou equivalente) para preservar os bytes exatos do body — `JSON.parse` + `JSON.stringify` muda a ordem das chaves e quebra a verificação HMAC (veja seção anterior). #### message.received Disparado quando uma mensagem e recebida no WhatsApp. Persistido na tabela `messages`. Para mensagens de mídia (image, video, audio, document, sticker), o arquivo e baixado do WhatsApp, armazenado no S3 e um registro `Media` e criado. O campo `media_id` pode ser usado para baixar a mídia via `GET /v1/instances/{instanceId}/media/{mediaId}`. Mensagens de `status@broadcast` e ecos da própria instância (`IsFromMe`) não geram esse evento. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "chat": "554192464230@s.whatsapp.net", "sender_lid": "216251804708983:34@lid", "chat_lid": "216251804708983@lid", "message_ids": ["550e8400-e29b-41d4-a716-446655440000"], "whatsapp_message_ids": ["3EB0ABC123DEF456"], "type": "audio", "content": "", "media_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "mime_type": "audio/ogg; codecs=opus", "file_size": 34567, "duration": 15, "quoted_msg_id": "d28b6749-0a5c-47bc-9c12-fd4537a5d149", "timestamp": 1741360245, "is_group": false, "push_name": "Joao Silva" } ``` **Exemplo de mensagem de grupo:** ```json { "phone": "554199999999", "from": "554199999999@s.whatsapp.net", "chat": "120363025555555555@g.us", "message_ids": ["76089f45-b309-43eb-9f2b-198ce302aa28"], "type": "text", "content": "oi pessoal", "timestamp": 1741360245, "is_group": true, "push_name": "Joao Silva", "group_name": "Vendas 2026", "group_subject": "Time comercial" } ``` > Para grupos: `chat` e o JID do grupo (`@g.us`), `from`/`phone`/`push_name` identificam o **participante** que enviou, e `group_name`/`group_subject` descrevem o grupo. Veja [Mensagens de Grupo](#mensagens-de-grupo) para o padrão completo. **Exemplo com contexto de anuncio (Instagram/Facebook click-to-WhatsApp):** ```json { "phone": "554188322497", "from": "554188322497@s.whatsapp.net", "chat": "554188322497@s.whatsapp.net", "message_ids": ["ACDFD74C4368A916E0FE"], "type": "text", "content": "Ola! Tenho interesse no tratamento com o iModel", "ad_context": { "source_type": "instagram", "source_app": "Instagram", "source_id": "ad-123", "title": "Tratamento iModel", "body": "Agende sua consulta agora", "media_type": "image", "ctwa_clid": "ARA...", "ref": "utm_campaign_xyz", "thumbnail_url": "https://media.biazap.com/biazap_abc/ads/thumbs/7a3b1c...d.jpg", "media_url": "https://media.biazap.com/biazap_abc/ads/media/91ef02...c.mp4" }, "is_forwarded": false, "timestamp": 1741360245, "is_group": false, "push_name": "Patricia" } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (ex: `"554192464230"`). Resolvido do registro de identidade quando o sender e um LID | | `from` | string | JID do remetente (resolvido para phone JID quando possível) | | `chat` | string | JID do chat (igual a `from` em DM, JID do grupo em grupo; resolvido para phone JID quando possível) | | `sender_lid` | string | LID original do remetente, presente apenas quando o WhatsApp usou LID internamente. Util para correlação (ver [seção 22](#22-identificadores-e-correlação-de-eventos)) | | `chat_lid` | string | LID original do chat, presente apenas quando o WhatsApp usou LID internamente | | `message_ids` | []string | Array com 1 UUID interno do Catcher (v4) — leia `message_ids[0]`. Identificador único e estável, nunca reciclado. | | `whatsapp_message_ids` | []string | ID(s) bruto(s) do WhatsApp que originaram o evento. Use apenas para auditoria/forense/correlação de baixo nível; para APIs e webhooks de negócio, prefira `message_ids` | | `type` | string | Tipo: text, image, video, audio, document, sticker, location, contact, reaction, poll, live_location, list, group_invite, view_once | | `content` | string | Texto, caption, ou emoji (para reactions) | | `media_url` | string | URL da mídia (deprecado, use `media_id`) | | `media_id` | string | ID da mídia para download via API (presente se mídia foi armazenada com sucesso) | | `mime_type` | string | MIME type da mídia (ex: `audio/ogg; codecs=opus`, `image/jpeg`) | | `file_name` | string | Nome do arquivo (presente para documentos) | | `file_size` | uint64 | Tamanho do arquivo em bytes (do proto WhatsApp) | | `duration` | uint32 | Duração em segundos (para audio e video) | | `quoted_msg_id` | string | UUID BiaZap (v4) da mensagem sendo respondida. Presente apenas em respostas/reply threading. Vazio se a mensagem citada não estiver no tenant DB (ex: reply a mensagem antiga de antes da instância entrar no chat). **Nunca** e o hex do WhatsApp — use este campo para correlacionar com `message_ids[0]` de eventos anteriores. | | `reaction_target_id` | string | UUID BiaZap (v4) da mensagem que recebeu a reaction (apenas para `type=reaction`). Vazio se o alvo não estiver no tenant DB. | | `ad_context` | object | Contexto de anuncio do Instagram/Facebook (presente apenas quando a mensagem originou de um click-to-WhatsApp ad). Ver campos abaixo | | `is_forwarded` | bool | `true` se a mensagem foi encaminhada. Omitido quando `false` | | `timestamp` | int64 | Unix timestamp | | `is_group` | bool | Se a mensagem veio de um grupo | | `push_name` | string | Nome do remetente no WhatsApp (em grupos, e o nome do **participante** que enviou) | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | **Campos de `ad_context`** (todos opcionais, presentes conforme disponível no proto): | Campo | Tipo | Descricao | |-------|------|-----------| | `source_type` | string | Plataforma de origem (ex: `"instagram"`, `"facebook"`) | | `source_app` | string | Nome do app de origem (ex: `"Instagram"`) | | `source_id` | string | Identificador do anuncio na plataforma | | `title` | string | Titulo/headline do anuncio | | `body` | string | Texto descritivo do anuncio | | `media_type` | string | Tipo de mídia do anuncio: `"image"`, `"video"`, ou vazio | | `ctwa_clid` | string | Click-to-WhatsApp Client ID (tracking) | | `ref` | string | Parametro de referência/tracking (ex: UTM campaign) | | `thumbnail_url` | string | **URL BiaZap R2** da thumbnail do anuncio. O worker baixa a imagem via proxy da instância e persiste em `{company}/ads/thumbs/{sha256}.{ext}` no R2, retornando a URL pública. Quando o proto traz a thumbnail inline (campo `Thumbnail []byte`), o upload e direto — sem HTTP. Campo vazio se o cache falhou ou a instância não tem proxy bindado. **Nunca** será URL Meta/CDN. | | `media_url` | string | **URL BiaZap R2** da mídia completa do anuncio (imagem/video grande). Worker baixa via proxy da instância e persiste em `{company}/ads/media/{sha256}.{ext}`. Campo vazio se cache falhou ou proxy indisponível. **Nunca** será URL Meta/CDN. Video: cap de 30 MB; excedeu, skip. | > **Proxy-discipline (licoes #29, #31, #35):** As URLs acima (`thumbnail_url`, `media_url`) são **sempre** URLs BiaZap — seja URL pública do R2 (quando `S3_PUBLIC_URL` está configurado) ou vazio. O worker NUNCA expoe URLs brutas do Meta (`cdninstagram.com`, `fbcdn.net`, `scontent.*`, `facebook.com/*/videos/*`) ao consumer. Todas as imagens/videos de anuncios são baixados pelo proxy dedicado da instância e republicados via R2 com dedup por sha256 do conteúdo (mesma thumbnail em 100 instâncias da mesma empresa = 1 objeto no R2). O campo `source_url` do proto Meta (permalink do post Instagram/Facebook) permanece intencionalmente removido — consumidores podem reconstruir referência via `source_id` + `source_type` quando necessário. Cache e best-effort: uma falha de rede ou ausência de binding emite campo vazio, sem quebrar o webhook. > **Reactions:** Quando `type=reaction`, o campo `content` contem o emoji (ex: `"❤️"`). Um `content` vazio indica que a reaction foi removida. O campo `reaction_target_id` contem o ID da mensagem que recebeu a reaction. --- #### message.sent Disparado após envio bem-sucedido de qualquer mensagem via API. Persistido na tabela `messages`. Para mensagens de mídia, inclui `media_id` para download via `GET /v1/instances/{instanceId}/media/{mediaId}`. ```json { "phone": "554192464230", "to": "554192464230@s.whatsapp.net", "chat": "554192464230@s.whatsapp.net", "chat_lid": "42812401807390@lid", "message_ids": ["550e8400-e29b-41d4-a716-446655440001"], "whatsapp_message_ids": ["3EB0DEF456ABC123"], "type": "image", "content": "Foto do produto", "media_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "mime_type": "image/jpeg", "file_name": "produto.jpg", "file_size": 123456, "source": "api", "idempotency_key": "msg-2026-03-21-0001", "quoted_msg_id": "3EB0AAA111BBB222", "timestamp": 1741360300, "is_group": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (ex: `"554192464230"`). Para mensagens externas com LID, resolvido do registro de identidade; pode ser vazio no primeiro contato | | `to` | string | JID do destinatário (resolvido para phone JID quando possível) | | `chat` | string | JID do chat (igual a to em DM, JID do grupo em grupo; resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente apenas quando o destinatário usava LID internamente. Util para correlação com receipts (ver [seção 22](#22-identificadores-e-correlação-de-eventos)) | | `message_ids` | []string | Array com 1 UUID interno do Catcher (v4) — leia `message_ids[0]`. Identificador único e estável, nunca reciclado. **`message_ids[0]` e o mesmo UUID retornado em `message_id` na resposta 202 do endpoint de envio** — use este valor para correlacionar o webhook com a request original. | | `whatsapp_message_ids` | []string | ID(s) bruto(s) do WhatsApp retornados pelo envio. Útil para auditoria e comparação com traces whatsmeow; não substitui o UUID Catcher em `message_ids` | | `type` | string | Tipo da mensagem (text, image, video, audio, document, sticker, location, contact, reaction, poll, template, forward) | | `content` | string | Conteúdo/caption da mensagem, ou emoji (para reactions) | | `source` | string | Origem do envio: `"api"` (enviado pela BiaZap) ou `"external"` (enviado pelo WhatsApp Web, telefone ou outra API) | | `idempotency_key` | string | Eco do header `Idempotency-Key` da request original. Presente apenas quando `source="api"`. Util para casar o webhook com a request do cliente quando multiplas requests compartilham o mesmo destinatário no curto prazo. Ausente em outbound externo (WhatsApp Web/celular/outra API). | | `media_id` | string | ID da mídia para download via API (presente para mensagens de mídia) | | `mime_type` | string | MIME type da mídia | | `file_name` | string | Nome do arquivo | | `file_size` | int64 | Tamanho do arquivo em bytes | | `quoted_msg_id` | string | UUID BiaZap (v4) da mensagem sendo respondida. Presente apenas em respostas/reply threading. Vazio se a mensagem citada não estiver no tenant DB (ex: reply a mensagem antiga de antes da instância entrar no chat). **Nunca** e o hex do WhatsApp — use este campo para correlacionar com `message_ids[0]` de eventos anteriores. | | `reaction_target_id` | string | UUID BiaZap (v4) da mensagem que recebeu a reaction (apenas para `type=reaction`). Vazio se o alvo não estiver no tenant DB. | | `timestamp` | int64 | Unix timestamp do envio | | `is_group` | bool | Se a mensagem foi enviada para um grupo | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | > **Captura de outbound externo:** Mensagens enviadas por WhatsApp Web, pelo celular ou por outra API conectada ao mesmo número são capturadas automaticamente como `message.sent` com `source: "external"`. Isso permite rastrear toda a comunicação outbound independente de onde foi originada. Mensagens enviadas pela própria BiaZap via fila tem `source: "api"`. > A persistencia no banco também distingue: a coluna `source` na tabela `messages` armazena `"api"` ou `"external"`. > Em mensagens externas, `quoted_msg_id` e `reaction_target_id` também são preservados quando presentes, inclusive para `message.sent` de grupos (`is_group=true`). > > **LID em mensagens externas:** Quando uma mensagem e enviada por outro dispositivo (WhatsApp Web, celular) para um contato cujo chat aparece como LID, a BiaZap resolve o telefone usando 3 camadas de fallback: > 1. **RecipientAlt** (protocolo): o WhatsApp envia `peer_recipient_pn` no evento, contendo o telefone do destinatário — está e a fonte primaria e mais confiavel para outbound echo. > 2. **MySQL** (`contact_identities`): registro persistido de interacoes anteriores (SenderAlt, RecipientAlt, queries de contato). > 3. **SQLite** (`whatsmeow_lid_map`): cache do whatsmeow populado por syncs de grupo, mensagens de migração LID, e operações de envio. > > Se o contato já foi visto em qualquer dessas fontes, `phone`, `to` e `chat` conterao o telefone resolvido. Caso contrario, `phone` ficara vazio e `chat_lid` contera o LID original — quando o contato responder (trazendo `SenderAlt`) ou aparecer em um sync de grupo, o mapeamento LID→telefone será aprendido automaticamente para futuras consultas. > > **Nota:** Não existe API no protocolo WhatsApp para resolver LID→telefone sob demanda. `GetUserInfo(lid)` não retorna o telefone (so funciona na direcao PN→LID). --- #### message.delivered Disparado quando o destinatário recebe a mensagem (dois checks cinza). Atualiza `status=delivered` e `delivered_at` na tabela `messages`. ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440001", "550e8400-e29b-41d4-a716-446655440002"], "chat": "554192464230@s.whatsapp.net", "sender": "554192464230@s.whatsapp.net", "chat_lid": "42812401807390@lid", "sender_lid": "42812401807390:77@lid", "whatsapp_message_ids": ["3EB0DEF456ABC123", "3EB0DEF456ABC124"], "status": "delivered", "timestamp": 1741360310, "is_group": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (ex: `"554192464230"`). Resolvido via 5 niveis de fallback: JID direto → MySQL contact_identities por chat LID → MySQL por sender LID → SQLite whatsmeow_lid_map → busca na tabela messages | | `message_ids` | []string | UUIDs internos do Catcher. Identificadores únicos e estáveis. | | `whatsapp_message_ids` | []string | ID(s) bruto(s) do WhatsApp recebidos no receipt. Pode ter múltiplos elementos quando o WhatsApp agrega receipts | | `chat` | string | JID do chat (resolvido para phone JID quando possível) | | `sender` | string | JID de quem recebeu (resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente quando o WhatsApp usou LID internamente | | `sender_lid` | string | LID original do sender (com sufixo de dispositivo), presente quando o WhatsApp usou LID internamente | | `status` | string | Sempre "delivered" | | `timestamp` | int64 | Unix timestamp da entrega | | `is_group` | bool | Se o chat e um grupo | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | --- #### message.read Disparado quando o destinatário le a mensagem (dois checks azuis). Atualiza `status=read` e `read_at` na tabela `messages`. ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440001"], "chat": "554192464230@s.whatsapp.net", "sender": "554192464230@s.whatsapp.net", "chat_lid": "42812401807390@lid", "sender_lid": "42812401807390:77@lid", "whatsapp_message_ids": ["3EB0DEF456ABC123"], "status": "read", "timestamp": 1741360350, "is_group": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo. Resolvido do registro de identidade quando o chat e um LID (mesma cadeia de fallback que `message.delivered`) | | `message_ids` | []string | UUIDs internos do Catcher. Identificadores únicos e estáveis. | | `whatsapp_message_ids` | []string | ID(s) bruto(s) do WhatsApp recebidos no receipt | | `chat` | string | JID do chat (resolvido para phone JID quando possível) | | `sender` | string | JID de quem leu (resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente quando o WhatsApp usou LID internamente | | `sender_lid` | string | LID original do sender, presente quando o WhatsApp usou LID internamente | | `status` | string | Sempre "read" | | `timestamp` | int64 | Unix timestamp da leitura | | `is_group` | bool | Se o chat e um grupo | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | --- #### message.played Disparado quando o destinatário abre uma mídia "visualizar uma vez" (view-once). Atualiza `status=played` e `played_at` na tabela `messages`. ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440001"], "chat": "554192464230@s.whatsapp.net", "sender": "554192464230@s.whatsapp.net", "chat_lid": "42812401807390@lid", "sender_lid": "42812401807390:77@lid", "whatsapp_message_ids": ["3EB0DEF456ABC123"], "status": "played", "timestamp": 1741360400, "is_group": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (mesma cadeia de fallback que `message.delivered`) | | `message_ids` | []string | UUIDs internos do Catcher. Identificadores únicos e estáveis. | | `whatsapp_message_ids` | []string | ID(s) bruto(s) do WhatsApp recebidos no receipt | | `chat` | string | JID do chat (resolvido para phone JID quando possível) | | `sender` | string | JID de quem abriu (resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente quando o WhatsApp usou LID internamente | | `sender_lid` | string | LID original do sender, presente quando o WhatsApp usou LID internamente | | `status` | string | Sempre "played" | | `timestamp` | int64 | Unix timestamp da abertura | | `is_group` | bool | Se o chat e um grupo | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | --- #### message.deleted Disparado quando uma mensagem e apagada ("apagar para todos"). Atualiza `status=deleted` e `deleted_at` na tabela `messages`. Cobre **ambas as direcoes**: mensagens inbound apagadas pelo contato (`is_from_me=false`) e mensagens outbound apagadas pelo operador via WhatsApp phone/Web (`is_from_me=true`). ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440000"], "chat": "554192464230@s.whatsapp.net", "sender": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "sender_lid": "216251804708983:34@lid", "whatsapp_message_ids": ["3EB0ABC123DEF456"], "is_from_me": false, "timestamp": 1741360400 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `message_ids` | []string | Array com 1 UUID interno do Catcher (v4) — leia `message_ids[0]`. Identificador único e estável, nunca reciclado. | | `whatsapp_message_ids` | []string | ID bruto do WhatsApp da mensagem alvo da revogação. Presente mesmo quando o UUID Catcher não foi encontrado | | `chat` | string | JID do chat (resolvido para phone JID quando possível) | | `sender` | string | JID de quem apagou (resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente quando o WhatsApp usou LID internamente | | `sender_lid` | string | LID original do sender, presente quando o WhatsApp usou LID internamente | | `is_from_me` | bool | Se foi a própria instância que apagou | | `is_group` | bool | Se a mensagem apagada pertencia a um grupo | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | | `timestamp` | int64 | Unix timestamp | --- #### message.edited Disparado quando uma mensagem e editada. Atualiza `content` na tabela `messages`. Cobre **ambas as direcoes**: mensagens inbound editadas pelo contato (`is_from_me=false`) e mensagens outbound editadas pelo operador via WhatsApp phone/Web (`is_from_me=true`). ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440000"], "chat": "554192464230@s.whatsapp.net", "sender": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "sender_lid": "216251804708983:34@lid", "whatsapp_message_ids": ["3EB0ABC123DEF456"], "new_content": "Texto editado da mensagem", "is_from_me": false, "is_group": false, "push_name": "Joao", "timestamp": 1741360450 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `message_ids` | []string | Array com 1 UUID interno do Catcher (v4) — leia `message_ids[0]`. Identificador único e estável, nunca reciclado. | | `whatsapp_message_ids` | []string | ID bruto do WhatsApp da mensagem alvo da edição. Presente mesmo quando o UUID Catcher não foi encontrado | | `chat` | string | JID do chat (resolvido para phone JID quando possível) | | `sender` | string | JID de quem editou (resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente quando o WhatsApp usou LID internamente | | `sender_lid` | string | LID original do sender, presente quando o WhatsApp usou LID internamente | | `new_content` | string | Novo conteúdo da mensagem | | `is_from_me` | bool | Se foi a própria instância que editou | | `is_group` | bool | Se a mensagem e de um grupo | | `push_name` | string | Nome do remetente (opcional) | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | | `timestamp` | int64 | Unix timestamp | --- #### call.received Disparado quando a instância recebe uma chamada. Persistido na tabela `call_logs`. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "call_id": "CALL-ABC123", "timestamp": 1741360500 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `from` | string | JID de quem ligou | | `call_id` | string | ID único da chamada | | `timestamp` | int64 | Unix timestamp | --- #### connection.update Disparado quando o status da conexão WhatsApp muda. Persistido na tabela `instances`. ```json { "status": "connected", "reason": "", "ban_expiry": null } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `status` | string | "connected", "disconnected", "banned", "logged_out", "replaced", "connect_failure", "client_outdated", "stream_error", "keepalive_timeout", "keepalive_restored" | | `reason` | string | Motivo do ban ou da desconexao (quando disponível) | | `ban_expiry` | int64/null | Unix timestamp de quando o ban expira (apenas para "banned") | > **Novos status:** > - `connect_failure` — falha ao conectar ao servidor WhatsApp (ex: erro de rede, autenticação falhou) > - `client_outdated` — versão do cliente WhatsApp está desatualizada e precisa ser atualizada > - `stream_error` — erro no stream de comunicação com o servidor WhatsApp > - `keepalive_timeout` — keepalive não recebeu resposta a tempo; conexão pode estar instável > - `keepalive_restored` — keepalive voltou a funcionar normalmente após um timeout --- #### presence.update Disparado quando um contato fica online/offline ou está digitando. **Evento transiente, não persistido no banco.** > **Nota:** Para receber presença de digitacao, a instância precisa estar com presença `available`. Para presença online/offline, a BiaZap so passa a monitorar após `POST /v1/instances/{instanceId}/contacts/{contactId}/presence/subscribe`. > **Snapshot opcional:** `GET /v1/instances/{instanceId}/contacts/{contactId}/presence` le o último estado conhecido em Redis (`online`, `last_seen`, `typing_state`). Isso não substitui SSE/WebSocket/webhook; e apenas um read model transiente para UX. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "available": true, "last_seen": 1741360000, "state": "composing", "is_chat": true, "chat": "554192464230@s.whatsapp.net", "is_group": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio para grupos) | | `from` | string | JID do contato | | `available` | bool | Se está disponível/digitando | | `last_seen` | int64 | Último visto (unix, 0 se oculto). Apenas para online/offline | | `state` | string | "composing", "paused", "recording" (apenas para `is_chat=true`) | | `is_chat` | bool | `true` = digitacao em chat, `false` = online/offline | | `chat` | string | JID do chat onde está digitando (apenas para `is_chat=true`) | | `is_group` | bool | Se e em um grupo | --- #### group.update Disparado para qualquer mudanca em grupo. Persistido na tabela `group_events`. ```json { "group_jid": "120363025555555555@g.us", "group_name": "Vendas 2026", "group_subject": "Time comercial", "action": "participant_join", "sender": "554192464230@s.whatsapp.net", "timestamp": 1741360600, "value": "", "members": ["554199999999@s.whatsapp.net", "554188888888@s.whatsapp.net"] } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `group_jid` | string | JID do grupo | | `group_name` | string | Nome atual do grupo (cache). Em `name_change` reflete o novo nome já atualizado. Pode estar vazio se for a primeira vez que esse grupo aparece em um evento (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao atual do grupo (cache). Em `topic_change` reflete o novo topico | | `action` | string | Tipo da mudanca (veja tabela abaixo) | | `sender` | string | JID de quem fez a mudanca | | `timestamp` | int64 | Unix timestamp | | `value` | string | Valor novo (nome, topico, picture_id) | | `members` | []string | JIDs dos membros afetados | **Ações possíveis:** | Action | Descricao | `value` | `members` | |--------|-----------|---------|-----------| | `name_change` | Nome do grupo alterado | Novo nome | - | | `topic_change` | Descricao alterada | Nova descricao | - | | `participant_join` | Membros entraram | - | JIDs que entraram | | `participant_leave` | Membros sairam | - | JIDs que sairam | | `participant_promote` | Promovidos a admin | - | JIDs promovidos | | `participant_demote` | Rebaixados de admin | - | JIDs rebaixados | | `locked` | Apenas admins editam info | - | - | | `unlocked` | Todos editam info | - | - | | `announce` | Apenas admins enviam msgs | - | - | | `unannounce` | Todos enviam msgs | - | - | | `picture_change` | Foto do grupo alterada | Picture ID | - | | `picture_remove` | Foto do grupo removida | - | - | | `ephemeral_change` | Mensagens temporarias ativadas/desativadas | Duração em segundos (0 = desativado) | - | | `membership_approval_on` | Aprovacao de membros ativada | - | - | | `membership_approval_off` | Aprovacao de membros desativada | - | - | | `group_deleted` | Grupo foi excluido | - | - | | `group_linked` | Grupo foi vinculado a uma comunidade | JID da comunidade | - | | `group_unlinked` | Grupo foi desvinculado de uma comunidade | JID da comunidade | - | | `invite_link_reset` | Link de convite foi redefinido | Novo link | - | | `suspended` | Grupo foi suspenso pelo WhatsApp | - | - | | `unsuspended` | Grupo foi reativado pelo WhatsApp | - | - | ##### Mensagens de Grupo (semantica + cache) Quando uma mensagem chega de um grupo, os campos do payload tem a seguinte semantica: | Campo | DM (1:1) | Grupo | |-------|----------|-------| | `chat` | JID do contato (`@s.whatsapp.net`) | **JID do grupo (`@g.us`)** | | `from` | JID do contato | **JID do participante que enviou** (resolvido para phone JID quando possível) | | `phone` | Telefone do contato | **Telefone do participante** que enviou | | `push_name` | Nome do contato | **Nome do participante** que enviou | | `is_group` | `false` | `true` | | `group_name` | omitido | Nome do grupo (cache, ver abaixo) | | `group_subject` | omitido | Topico/descricao do grupo (cache, ver abaixo) | > **Padrão de renderizacao:** Para mostrar uma mensagem de grupo no estilo WhatsApp Web, use `group_name` como titulo da conversa e `push_name` como autor do balao individual. `chat` e o identificador único da conversa de grupo (sempre termina em `@g.us`). **Cache de metadados de grupo (lazy):** `group_name` e `group_subject` são populados a partir de um cache em memória por instância. O cache funciona assim: 1. **Primeira mensagem de um grupo novo:** o cache está vazio. O evento e emitido com `group_name=""` e `group_subject=""`. Em paralelo, a BiaZap dispara uma busca assincrona via `GetGroupInfo` para popular o cache. 2. **Mensagens subsequentes:** o cache já tem o nome, entao todos os eventos do mesmo grupo virao com `group_name` e `group_subject` preenchidos. 3. **Eventos `events.GroupInfo`** com `name_change` ou `topic_change` atualizam o cache imediatamente, antes mesmo de emitir o `group.update` correspondente. 4. **Logout/delete da instância** limpa o cache para evitar leakage entre sessões. **Quais eventos incluem `group_name` / `group_subject`?** | Evento | `group_name` | `group_subject` | |--------|--------------|-----------------| | `message.received` | Sim | Sim | | `message.sent` | Sim | Sim | | `message.delivered` | Sim | Sim | | `message.read` | Sim | Sim | | `message.played` | Sim | Sim | | `message.deleted` | Sim | Sim | | `message.edited` | Sim | Sim | | `group.update` | Sim | Sim | | `group.joined` | Sim (`group_name`/`group_topic` já existiam) | Sim | **Fallback para grupos novos:** Se o seu consumidor recebe um evento de grupo com `group_name` vazio, você pode chamar `GET /v1/instances/{instanceId}/groups/{groupJid}` para resolver o nome no momento (e isso já vai popular o cache para os próximos eventos). --- #### contact.update Disparado quando a foto de perfil, nome ou status de um contato muda. Persistido na tabela `contact_events`. Quando o contato e identificado por LID (Linked Device ID), o campo `jid` mostra o JID do telefone (se resolvido) e `jid_lid` preserva o LID original. Se o LID não puder ser resolvido, `jid` contera o LID e `phone` ficara vazio. ```json { "phone": "554192464230", "jid": "554192464230@s.whatsapp.net", "jid_lid": "273293080838230@lid", "action": "picture_change", "value": "j+rE", "timestamp": 1741360700 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio se LID não resolvido ou grupo) | | `jid` | string | JID do contato (telefone resolvido quando possível) | | `jid_lid` | string | LID original quando o JID foi resolvido de um LID (vazio se já era telefone) | | `action` | string | Tipo da mudanca (veja tabela abaixo) | | `value` | string | Valor novo (ID/hash da foto, novo nome, etc.) | | `old_value` | string | Valor anterior (presente apenas para `push_name_change` e `business_name_change`) | | `timestamp` | int64 | Unix timestamp | **Ações possíveis:** | Action | Descricao | `value` | `old_value` | |--------|-----------|---------|-------------| | `picture_change` | Foto de perfil alterada | Picture ID ou hash | - | | `picture_remove` | Foto de perfil removida | - | - | | `about_change` | Status/about alterado | Novo status | - | | `push_name_change` | Nome de exibição alterado | Novo nome | Nome anterior | | `business_name_change` | Nome comercial alterado | Novo nome | Nome anterior | > **Avatar cache:** Quando `action` e `picture_change`, a BiaZap automaticamente baixa a nova foto do contato e armazena no S3/R2. Quando `action` e `picture_remove`, o cache e limpo. Clientes podem usar este evento para invalidar avatares em cache local e buscar a nova versão via `GET /v1/instances/{instanceId}/contacts/{phone}/avatar`. --- #### instance.banned Disparado quando a instância recebe um ban temporario ou permanente do WhatsApp. Persistido nos campos `ban_expiry` e `ban_reason` da instância. Diferente de `connection.update` com `status: "banned"`, este evento traz informações detalhadas do ban. ```json { "instance_id": "84c2e480-...", "permanent": false, "reason": "spam", "ban_expiry": 1741446000 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `instance_id` | string | ID da instância banida | | `permanent` | bool | `true` = ban permanente, `false` = temporario | | `reason` | string | Motivo do ban (quando disponível) | | `ban_expiry` | int64/null | Unix timestamp de quando o ban expira (null se permanente) | > **Nota:** O evento `connection.update` com `status: "banned"` também e emitido. Use `instance.banned` quando precisar de detalhes do ban (permanencia, motivo, expiracao). --- #### instance.offline / instance.critical_offline / instance.recovered Eventos do monitor de saúde (`internal/health/monitor.go`). Disparados quando uma instância cruza um dos thresholds de tempo offline: - **`instance.offline`** — instância desconectada ha 15min ou mais (transicao única do tier `degraded` para `offline`). - **`instance.critical_offline`** — instância desconectada ha 6h ou mais (transicao do tier `offline` para `critical_offline`). Também dispara email para o owner da empresa + todos os superadmins. - **`instance.recovered`** — instância voltou a conectar após ter ficado em `offline`/`critical_offline` (limpa o `offline_alert_level`). Cada evento e disparado **exatamente uma vez** por transicao. O monitor armazena o nivel atual em `offline_alert_level` na linha da instância para evitar re-emissoes a cada scan (60s). Instâncias com `desired_state=DISCONNECTED` (pausadas manualmente) **nunca disparam** estes eventos. ```json { "instance_id": "84c2e480-...", "name": "EJ Whats", "phone": "554192464230", "tier": "critical_offline", "previous_tier": "offline", "status": "DISCONNECTED", "desired_state": "CONNECTED", "disconnected_at": "2026-04-06T21:44:00Z", "last_activity_at": "2026-04-06T21:42:18Z", "offline_duration_seconds": 158400, "offline_duration_human": "1d 20h", "reason": "", "timestamp": 1744156800 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `instance_id` | string | ID da instância | | `name` | string | Nome configurado da instância | | `phone` | string | Telefone E.164 da instância (vazio se nunca conectou) | | `tier` | string | Tier no momento da emissao: `offline`, `critical_offline`, `healthy`, `stale`, etc. | | `previous_tier` | string | Tier antes da transicao (util para correlacionar) | | `status` | string | Status WhatsApp bruto: `CONNECTED`, `DISCONNECTED`, etc. | | `desired_state` | string | `CONNECTED` (queremos restaurar) ou `DISCONNECTED` (pausada) | | `disconnected_at` | string/null | Timestamp ISO-8601 da primeira deteccao do drop. Preserva o tempo original em flapping. | | `last_activity_at` | string/null | Último heartbeat (mensagem inbound, send outbound, ou receipt processado) | | `offline_duration_seconds` | int64 | Duração calculada no momento da emissao | | `offline_duration_human` | string | Versão formatada (`"2d 4h"`, `"30m"`) para display direto | | `reason` | string | Conteúdo de `last_error` se houver | | `timestamp` | int64 | Unix seconds da emissao | > **Configuração:** Os thresholds (15min e 6h) são constantes no código (`internal/health/tier.go`) para garantir visibilidade em code review. Para tunar, edite `OfflineThreshold` e `CriticalThreshold`. > **Endpoint relacionado:** `GET /v1/admin/instance-health` retorna o rollup completo cross-tenant em tempo real (usado pelo banner do dashboard, badge da sidebar, e card "Saúde das instâncias"). --- #### message.undecryptable Disparado quando uma mensagem e recebida mas não pode ser decriptada pela instância. Isso pode acontecer quando a sessão de criptografia está dessincronizada ou quando a mensagem foi enviada para um dispositivo anterior. Não cria uma linha normal em `messages`, porque o conteúdo não foi aberto; o evento fica disponível em webhooks/SSE/WS e no histórico de eventos. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "chat": "554192464230@s.whatsapp.net", "message_ids": ["3EB0ABC123DEF456"], "whatsapp_message_ids": ["3EB0ABC123DEF456"], "is_unavailable": true, "unavailable_type": "account_removed", "decrypt_fail_mode": "", "timestamp": 1741360800 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `from` | string | JID do remetente | | `chat` | string | JID do chat | | `message_ids` | []string | Array com 1 elemento (`message_ids[0]`) contendo o ID hexadecimal do WhatsApp. **Exceção:** `message.undecryptable` carrega ID do WhatsApp e não UUID Catcher, porque a mensagem não pode ser persistida. | | `whatsapp_message_ids` | []string | Mesmo ID bruto do WhatsApp, exposto explicitamente para correlação com traces e eventos `message.decrypt_recovered` / `message.decrypt_lost` | | `is_unavailable` | bool | `true` se a mensagem foi marcada como indisponível pelo servidor | | `unavailable_type` | string | Tipo de indisponibilidade (ex: `"account_removed"`, vazio se não aplicavel) | | `decrypt_fail_mode` | string | Modo de falha da decriptacao (ex: `"no_session"`, vazio se `is_unavailable=true`) | | `timestamp` | int64 | Unix timestamp | --- #### message.decrypt_recovered Disparado quando uma mensagem que antes gerou `message.undecryptable` chega novamente e é decriptada com sucesso pelo retry automático do WhatsApp/whatsmeow. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "chat": "554192464230@s.whatsapp.net", "message_ids": ["3EB0ABC123DEF456"], "whatsapp_message_ids": ["3EB0ABC123DEF456"], "status": "recovered", "recovery_ms": 831, "timestamp": 1741360801 } ``` #### message.decrypt_lost Disparado quando uma mensagem que gerou `message.undecryptable` não reaparece dentro da janela de retry. Esse evento indica perda real de conteúdo, não apenas ruído transitório. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "chat": "554192464230@s.whatsapp.net", "message_ids": ["3EB0ABC123DEF456"], "whatsapp_message_ids": ["3EB0ABC123DEF456"], "status": "lost", "ttl_secs": 300, "timestamp": 1741361100 } ``` Campos adicionais opcionais nos dois eventos: `sender_name`, `is_group`, `group_name`. --- #### message.starred Disparado quando uma mensagem e marcada ou desmarcada com estrela. Evento de sincronização entre dispositivos; não persistido em tabela separada. ```json { "chat": "554192464230@s.whatsapp.net", "message_ids": ["550e8400-e29b-41d4-a716-446655440000"], "whatsapp_message_ids": ["3EB0ABC123DEF456"], "starred": true, "is_from_me": false, "timestamp": 1741360850 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `chat` | string | JID do chat | | `message_ids` | []string | Array com 1 UUID interno do Catcher (v4) — leia `message_ids[0]`. Identificador único e estável, nunca reciclado. | | `whatsapp_message_ids` | []string | ID bruto do WhatsApp da mensagem marcada/desmarcada | | `starred` | bool | `true` = marcada com estrela, `false` = desmarcada | | `is_from_me` | bool | Se a mensagem e da própria instância | | `timestamp` | int64 | Unix timestamp | --- #### message.deleted_for_me Disparado quando uma mensagem e apagada localmente ("apagar para mim"), sem afetar os outros participantes. Diferente de `message.deleted`, que e "apagar para todos". Não persistido. ```json { "chat": "554192464230@s.whatsapp.net", "message_ids": ["550e8400-e29b-41d4-a716-446655440000"], "whatsapp_message_ids": ["3EB0ABC123DEF456"], "is_from_me": false, "timestamp": 1741360900 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `chat` | string | JID do chat | | `message_ids` | []string | Array com 1 UUID interno do Catcher (v4) — leia `message_ids[0]`. Identificador único e estável, nunca reciclado. | | `whatsapp_message_ids` | []string | ID bruto do WhatsApp da mensagem apagada localmente | | `is_from_me` | bool | Se a mensagem era da própria instância | | `timestamp` | int64 | Unix timestamp | --- #### call.accepted Disparado quando uma chamada feita pela instância e aceita pela parte remota. Persistido na tabela `call_logs`. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "call_id": "CALL-ABC123", "timestamp": 1741360950 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `from` | string | JID de quem aceitou | | `call_id` | string | ID único da chamada | | `timestamp` | int64 | Unix timestamp | --- #### call.rejected Disparado quando uma chamada e rejeitada pela parte remota. Persistido na tabela `call_logs`. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "call_id": "CALL-ABC123", "timestamp": 1741361000 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `from` | string | JID de quem rejeitou | | `call_id` | string | ID único da chamada | | `timestamp` | int64 | Unix timestamp | --- #### call.ended Disparado quando uma chamada e finalizada por qualquer motivo. Persistido na tabela `call_logs`. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "call_id": "CALL-ABC123", "reason": "normal", "duration": 45, "timestamp": 1741361050 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `from` | string | JID da outra parte | | `call_id` | string | ID único da chamada | | `reason` | string | Motivo do encerramento. Valores observados: `"reject"` (você recusou — também dispara `call.declined_by_me`), `"timeout"` (não atendeu), `""` (caller desligou), `"upgrade-needed"` (versão do cliente desatualizada), entre outros. Lista abaixo da seção. | | `duration` | int | Duração da chamada em segundos (0 se não atendida) | | `timestamp` | int64 | Unix timestamp | --- #### call.declined_by_me Disparado quando você (instância) recusa uma chamada de entrada. Há duas origens distintas, distinguidas pelo campo `source`: 1. **Manual** — o usuário tocou em recusar no app do WhatsApp do telefone pareado 2. **Automática** — o toggle `auto_reject_calls` da instância estava ativo no momento da oferta > **Por que existe este evento.** No protocolo do WhatsApp NÃO há um frame dedicado para "usuário recusou no telefone". O sinal canônico no fio é o `CallTerminate` cujo atributo `from` flipa para o JID/LID do PRÓPRIO dispositivo que rejeitou (em vez do JID do chamador original). Em logs de produção, o `reason` desse terminate vem vazio (`""`) — não `"reject"` como Baileys documenta para casos legados. A Catcher detecta os dois sinais (self-JID/LID match OU reason="reject") e recupera o JID do chamador original via stash interno populado no `CallOffer`. O evento `call.ended` continua disparando em paralelo carregando o mesmo `phone`/`from` recuperado. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "call_id": "CALL-ABC123", "source": "manual", "timestamp": 1741361050 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone do chamador | | `from` | string | JID do chamador | | `call_id` | string | ID único da chamada | | `source` | string | `"manual"` (recusa na UI do telefone) ou `"auto"` (toggle `auto_reject_calls` ativo) | | `timestamp` | int64 | Unix timestamp | --- #### call.group_offer Disparado quando a instância recebe uma chamada de grupo. Persistido na tabela `call_logs`. ```json { "call_id": "CALL-GRP456", "media": "audio", "type": "group", "timestamp": 1741361100 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `call_id` | string | ID único da chamada | | `media` | string | Tipo de mídia: `"audio"` ou `"video"` | | `type` | string | Sempre `"group"` | | `timestamp` | int64 | Unix timestamp | --- #### group.joined Disparado quando a instância e adicionada a um grupo (por convite, link ou admin). Persistido na tabela `group_events`. ```json { "group_jid": "120363025555555555@g.us", "group_name": "Vendas 2026", "group_topic": "Canal de vendas da equipe", "reason": "invite", "sender": "554192464230@s.whatsapp.net", "timestamp": 1741361150 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `group_jid` | string | JID do grupo | | `group_name` | string | Nome do grupo | | `group_topic` | string | Descricao/topico do grupo | | `reason` | string | Motivo da entrada (ex: `"invite"`, `"link"`, `"admin_add"`) | | `sender` | string | JID de quem adicionou (quando disponível) | | `timestamp` | int64 | Unix timestamp | --- #### blocklist.update Disparado quando a lista de contatos bloqueados e alterada. Não persistido. ```json { "action": "modify", "changes": [ { "phone": "554192464230", "jid": "554192464230@s.whatsapp.net", "action": "block" }, { "phone": "554188322497", "jid": "554188322497@s.whatsapp.net", "jid_lid": "216251804708983@lid", "action": "unblock" } ], "timestamp": 1741361200 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `action` | string | `"set"` (lista completa substituida) ou `"modify"` (alteração incremental) | | `changes` | array | Lista de alterações | | `changes[].phone` | string | Número de telefone sem sufixo | | `changes[].jid` | string | JID do contato (telefone resolvido quando possível) | | `changes[].jid_lid` | string | LID original quando resolvido de um LID | | `changes[].action` | string | `"block"` ou `"unblock"` | | `timestamp` | int64 | Unix timestamp | > **Nota:** Quando `action` e `"set"`, a lista `changes` representa a lista completa de contatos bloqueados (todos com `action: "block"`). Quando e `"modify"`, cada item em `changes` pode ser `"block"` ou `"unblock"`. --- #### contact.identity_changed Disparado quando um contato troca de dispositivo principal (re-registrou o WhatsApp em outro celular). A chave de criptografia do contato mudou. Persistido na tabela `contact_events`. ```json { "phone": "554192464230", "jid": "554192464230@s.whatsapp.net", "jid_lid": "273293080838230@lid", "implicit": false, "timestamp": 1741361250 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `jid` | string | JID do contato (telefone resolvido quando possível) | | `jid_lid` | string | LID original quando resolvido de um LID | | `implicit` | bool | `true` se a mudanca foi detectada implicitamente (sem notificação do servidor), `false` se o servidor notificou explicitamente | | `timestamp` | int64 | Unix timestamp | --- #### contact.sync Disparado quando um contato e sincronizado do dispositivo (agenda de contatos). Persistido na tabela `contact_events`. ```json { "phone": "554192464230", "jid": "554192464230@s.whatsapp.net", "first_name": "Joao", "full_name": "Joao Silva", "timestamp": 1741361300 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `jid` | string | JID do contato | | `first_name` | string | Primeiro nome do contato (da agenda) | | `full_name` | string | Nome completo do contato (da agenda) | | `timestamp` | int64 | Unix timestamp | --- #### chat.pin Disparado quando um chat e fixado ou desfixado. Evento de sincronização entre dispositivos. Não persistido. ```json { "phone": "554192464230", "chat": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "pinned": true, "timestamp": 1741361350 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio para grupos ou LID não resolvido) | | `chat` | string | JID do chat (telefone resolvido quando possível) | | `chat_lid` | string | LID original quando o chat foi resolvido de um LID | | `pinned` | bool | `true` = fixado, `false` = desfixado | | `timestamp` | int64 | Unix timestamp | --- #### chat.archive Disparado quando um chat e arquivado ou desarquivado. Evento de sincronização entre dispositivos. Não persistido. ```json { "phone": "554192464230", "chat": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "archived": true, "timestamp": 1741361400 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio para grupos ou LID não resolvido) | | `chat` | string | JID do chat (telefone resolvido quando possível) | | `chat_lid` | string | LID original quando o chat foi resolvido de um LID | | `archived` | bool | `true` = arquivado, `false` = desarquivado | | `timestamp` | int64 | Unix timestamp | --- #### chat.mute Disparado quando um chat e silenciado ou dessilenciado. Evento de sincronização entre dispositivos. Não persistido. ```json { "phone": "554192464230", "chat": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "muted": true, "mute_end": 1741447800, "timestamp": 1741361450 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio para grupos ou LID não resolvido) | | `chat` | string | JID do chat (telefone resolvido quando possível) | | `chat_lid` | string | LID original quando o chat foi resolvido de um LID | | `muted` | bool | `true` = silenciado, `false` = dessilenciado | | `mute_end` | int64 | Unix timestamp de quando o silenciamento expira (0 se silenciado indefinidamente ou se `muted=false`) | | `timestamp` | int64 | Unix timestamp | --- #### chat.read_state Disparado quando um chat e marcado como lido ou não lido. Emitido tanto por sincronização entre dispositivos (AppState) quanto pelas APIs `POST /v1/instances/{id}/mark-read` (com `mark_all: true`) e `POST /v1/instances/{id}/mark-unread`. Não persistido. ```json { "phone": "554192464230", "chat": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "marked_as_read": false, "timestamp": 1741361500 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio para grupos ou LID não resolvido) | | `chat` | string | JID do chat (telefone resolvido quando possível) | | `chat_lid` | string | LID original quando o chat foi resolvido de um LID | | `marked_as_read` | bool | `true` = marcado como lido, `false` = marcado como não lido | | `timestamp` | int64 | Unix timestamp | > **Nota:** Quando disparado via API (`mark-read` / `mark-unread`), o evento e emitido imediatamente após o `SendAppState` bem-sucedido, sem esperar o echo de sincronização do WhatsApp. --- #### chat.clear Disparado quando um chat e limpo (histórico apagado). Não persistido. ```json { "phone": "554192464230", "chat": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "delete_media": false, "timestamp": 1741361550 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio para grupos ou LID não resolvido) | | `chat` | string | JID do chat (telefone resolvido quando possível) | | `chat_lid` | string | LID original quando o chat foi resolvido de um LID | | `delete_media` | bool | `true` se a mídia também foi apagada | | `timestamp` | int64 | Unix timestamp | --- #### chat.delete Disparado quando um chat e apagado completamente. Não persistido. ```json { "phone": "554192464230", "chat": "554192464230@s.whatsapp.net", "chat_lid": "216251804708983@lid", "delete_media": true, "timestamp": 1741361600 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (vazio para grupos ou LID não resolvido) | | `chat` | string | JID do chat (telefone resolvido quando possível) | | `chat_lid` | string | LID original quando o chat foi resolvido de um LID | | `delete_media` | bool | `true` se a mídia também foi apagada | | `timestamp` | int64 | Unix timestamp | --- #### privacy.update Disparado quando as configurações de privacidade da instância são alteradas. Não persistido. ```json { "settings": { "group_add": "contacts", "last_seen": "everyone", "online": "match_last_seen", "profile_photo": "contacts", "status": "contacts", "read_receipts": "enabled", "call_add": "known" }, "changed_fields": ["group_add", "profile_photo"], "timestamp": 1741361650 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `settings` | object | Mapa de configurações de privacidade atuais (nome_config -> valor) | | `changed_fields` | []string | Lista dos campos que foram alterados nesta atualização | | `timestamp` | int64 | Unix timestamp | > **Configurações possíveis:** `group_add`, `last_seen`, `online`, `profile_photo`, `status`, `read_receipts`, `call_add`. Os valores dependem da configuração (ex: `"everyone"`, `"contacts"`, `"nobody"`, `"match_last_seen"`, `"enabled"`, `"disabled"`, `"known"`). --- #### history.sync Disparado quando uma sincronização de histórico e recebida do servidor WhatsApp (tipicamente após conectar um novo dispositivo ou após `POST /history/import`). As mensagens do blob são persistidas no histórico de conversas; o evento abaixo e o resumo da importação. ```json { "sync_type": "RECENT", "conversation_count": 42, "message_count": 1500, "pushname_count": 38, "imported_count": 1480, "duplicate_count": 20, "skipped_count": 0, "failed_count": 0, "timestamp": 1741361700 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `sync_type` | string | Tipo de sincronização (ex: `"RECENT"`, `"FULL"`, `"PUSH_NAME"`) | | `conversation_count` | int | Número de conversas sincronizadas | | `message_count` | int | Número de mensagens sincronizadas | | `pushname_count` | int | Número de push names sincronizados | | `imported_count` | int | Mensagens históricas gravadas em `messages` | | `duplicate_count` | int | Mensagens já existentes ignoradas por dedupe | | `skipped_count` | int | Itens sem mensagem/chave válida ou ignorados | | `failed_count` | int | Itens que falharam ao converter ou persistir | | `timestamp` | int64 | Unix timestamp | > Histórico importado não emite `message.received`/`message.sent` por mensagem, para evitar que consumidores processem mensagens antigas como tráfego novo. Use `history.sync` para progresso/resumo e `GET /chats/{chatId}/messages` para ler o conteúdo salvo. --- #### sync.preview Disparado quando um preview de sincronização offline e recebido, indicando quantos eventos estão pendentes. Não persistido. ```json { "total": 250, "messages": 180, "receipts": 45, "notifications": 25, "timestamp": 1741361750 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `total` | int | Total de eventos pendentes | | `messages` | int | Número de mensagens pendentes | | `receipts` | int | Número de receipts (entrega/leitura) pendentes | | `notifications` | int | Número de notificações pendentes | | `timestamp` | int64 | Unix timestamp | --- #### média.retry_result Disparado quando o resultado de um retry de download de mídia e recebido. Quando a mídia de uma mensagem recebida não pode ser baixada inicialmente, o WhatsApp pode reenviar os bytes — este evento indica o resultado dessa tentativa. Não persistido. ```json { "message_ids": ["3EB0ABC123DEF456"], "chat": "554192464230@s.whatsapp.net", "success": true, "timestamp": 1741361800 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `message_ids` | []string | Array com 1 elemento (`message_ids[0]`) contendo o UUID BiaZap da mensagem cuja mídia foi retentada | | `chat` | string | JID do chat | | `success` | bool | `true` se o retry foi bem-sucedido | | `error_code` | int | Código de erro (presente apenas quando `success=false`) | | `timestamp` | int64 | Unix timestamp | --- #### label.edit Disparado quando uma etiqueta e criada, editada ou removida. Etiquetas são um recurso do WhatsApp Business. Não persistido. ```json { "label_id": "5", "name": "Urgente", "color": 1, "deleted": false, "timestamp": 1741361850 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `label_id` | string | ID da etiqueta | | `name` | string | Nome da etiqueta | | `color` | int | Índice de cor da etiqueta (0-19 no WhatsApp Business) | | `deleted` | bool | `true` se a etiqueta foi removida | | `timestamp` | int64 | Unix timestamp | --- #### label.chat_association Disparado quando um chat e associado ou desassociado de uma etiqueta. Não persistido. ```json { "label_id": "5", "chat": "554192464230@s.whatsapp.net", "phone": "554192464230", "associated": true, "timestamp": 1741361900 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `label_id` | string | ID da etiqueta | | `chat` | string | JID do chat | | `phone` | string | Número de telefone sem sufixo (vazio para grupos) | | `associated` | bool | `true` = chat etiquetado, `false` = chat desetiquetado | | `timestamp` | int64 | Unix timestamp | --- #### label.message_association Disparado quando uma mensagem e associada ou desassociada de uma etiqueta. Não persistido. ```json { "label_id": "5", "chat": "554192464230@s.whatsapp.net", "message_ids": ["3EB0ABC123DEF456"], "associated": true, "timestamp": 1741361950 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `label_id` | string | ID da etiqueta | | `chat` | string | JID do chat | | `message_ids` | []string | Array com 1 elemento (`message_ids[0]`) contendo o UUID BiaZap da mensagem etiquetada | | `associated` | bool | `true` = mensagem etiquetada, `false` = mensagem desetiquetada | | `timestamp` | int64 | Unix timestamp | --- #### message.acked Disparado quando **outro dispositivo seu** (sync multi-device) confirma recebimento de uma mensagem que **você** enviou. Distinto de `message.delivered` — aquele e o **contato** confirmando entrega; este e o seu próprio segundo aparelho. Não persistido (não carimba `delivered_at` na tabela `messages`). ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440001"], "chat": "554192464230@s.whatsapp.net", "sender": "554192464230@s.whatsapp.net", "chat_lid": "42812401807390@lid", "sender_lid": "42812401807390:77@lid", "status": "acked", "timestamp": 1741360305, "is_group": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (mesma cadeia de fallback que `message.delivered`) | | `message_ids` | []string | UUIDs internos do Catcher (v4) das mensagens outbound confirmadas | | `chat` | string | JID do chat (resolvido para phone JID quando possível) | | `sender` | string | JID do dispositivo que confirmou (resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente quando o WhatsApp usou LID internamente | | `sender_lid` | string | LID original do sender, presente quando o WhatsApp usou LID internamente | | `status` | string | Sempre "acked" | | `timestamp` | int64 | Unix timestamp | | `is_group` | bool | Se o chat e um grupo | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | > Este evento também pode trazer `is_mutual` / `mutuality_checked_at` — ver a nota de mutualidade no início desta seção. --- #### message.read_other_device Disparado quando **você** le uma mensagem inbound em **outro dispositivo seu**. O contato não leu nada — e o seu próprio estado de leitura multi-device. Util para evitar re-notificar o operador de algo que ele já viu em outro aparelho. Não persistido (não carimba `read_at`, que e reservado para o receipt de leitura do contato sobre suas mensagens outbound). ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440000"], "chat": "554192464230@s.whatsapp.net", "sender": "554192464230@s.whatsapp.net", "chat_lid": "42812401807390@lid", "sender_lid": "42812401807390:77@lid", "status": "read_other_device", "timestamp": 1741360360, "is_group": false } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (mesma cadeia de fallback que `message.delivered`) | | `message_ids` | []string | UUIDs internos do Catcher (v4) das mensagens inbound lidas | | `chat` | string | JID do chat (resolvido para phone JID quando possível) | | `sender` | string | JID do remetente da mensagem inbound (resolvido para phone JID quando possível) | | `chat_lid` | string | LID original do chat, presente quando o WhatsApp usou LID internamente | | `sender_lid` | string | LID original do sender, presente quando o WhatsApp usou LID internamente | | `status` | string | Sempre "read_other_device" | | `timestamp` | int64 | Unix timestamp | | `is_group` | bool | Se o chat e um grupo | | `group_name` | string | Nome do grupo. Presente apenas quando `is_group=true` e o cache já foi populado (ver [Mensagens de Grupo](#mensagens-de-grupo)) | | `group_subject` | string | Topico/descricao do grupo. Presente apenas quando `is_group=true` e o cache já foi populado | > Este evento também pode trazer `is_mutual` / `mutuality_checked_at` — ver a nota de mutualidade no início desta seção. --- #### connection.diagnostic Timeline fina do ciclo de conexão — emitido em pontos-chave do `ConnectInstance` do worker (dialing, QR emitido, WebSocket fechado, etc.). Usado pela modal de QR do frontend para mostrar exatamente o que está acontecendo em tempo real. Não persistido. Carrega **apenas** dados seguros (IP do proxy, sinais de ciclo de vida do whatsmeow, mensagens curtas) — nunca URLs de CDN do Meta, telefones de terceiros ou conteúdo de mensagem. ```json { "step": "qr_emitted", "level": "info", "message": "QR Code gerado, aguardando leitura", "detail": { "proxy_city": "sao-paulo" }, "timestamp": 1741360100 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `step` | string | Slug estável do momento do ciclo de vida. Vocabulario: `connect_start`, `proxy_bound`, `dialing`, `websocket_opened`, `qr_emitted`, `pair_device_received`, `pair_success`, `pair_error`, `connected`, `disconnected`, `websocket_closed`, `logged_out`, `connect_failure`, `stream_replaced`, `temp_ban`, `keepalive_timeout`, `keepalive_restored`, `reconnecting`, `flap_detected`, `manual_disconnect`, `quarantine_applied` | | `level` | string | `info`, `success`, `warning` ou `error` — controla a cor do badge na UI | | `message` | string | Resumo de uma linha em pt-BR, legivel por humano | | `detail` | object | Contexto estruturado opcional (IP do proxy, motivo da falha, etc.) | | `timestamp` | int64 | Unix timestamp | --- #### contacts.bulk_imported Disparado quando o worker absorve um roster de contatos em massa de uma so vez — hoje apenas no `HistorySync` de `INITIAL_BOOTSTRAP` que segue um pareamento novo. CRMs podem usar isso como um único sinal "usuário acabou de parear, aqui estão os contatos" em vez de esperar o registro preencher incrementalmente. Não persistido como evento (os contatos em si vao para a tabela `contacts`). ```json { "source": "history_sync_initial_bootstrap", "imported": 312, "skipped": 4, "timestamp": 1741361000 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `source` | string | Caminho do bulk-import. Hoje sempre `"history_sync_initial_bootstrap"`; reservado para caminhos futuros | | `imported` | int | Quantidade de contatos absorvidos | | `skipped` | int | Quantidade de contatos ignorados (já conhecidos ou inválidos). Omitido quando 0 | | `timestamp` | int64 | Unix timestamp | --- #### sync.offline_completed Disparado quando o whatsmeow termina de absorver eventos perdidos que chegaram durante uma janela de `Disconnected`. `count > 0` significa que houve um gap real de entrega — a instância agora tem dados novos que o consumer não viu em tempo real. `count == 0` significa que a reconexao cobriu o gap sem tráfego perdido (saudável). Não persistido. ```json { "count": 7, "timestamp": 1741361050 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `count` | int | Número de eventos perdidos absorvidos. 0 = reconexao sem gap | | `timestamp` | int64 | Unix timestamp | --- #### connection.recovered_quick Disparado quando um `Connected` segue um `Disconnected` dentro de uma janela curta (~60s). Distinto de `instance.recovered`, que exige >=15min de offline observavel. Util para dashboards que querem rastrear ruido de estabilidade de conexão separadamente de outages reais. Não persistido. ```json { "duration_seconds": 12.4, "timestamp": 1741361080 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `duration_seconds` | float64 | Quanto tempo a instância ficou desconectada antes de voltar | | `timestamp` | int64 | Unix timestamp | --- ### Payloads adicionais de eventos Os eventos abaixo também fazem parte de `AllEventTypes`. Os exemplos usam o envelope real `{type,data}` e os campos vêm dos `*Data` structs em `whats-shared/pkg/realtime/event.go`. #### status.received Campos: `phone`, `from`, `status_message_id`, `type`, `content`, `media_id`, `mime_type`, `timestamp`. ```json { "type": "status.received", "data": { "phone": "554137984905", "from": "554137984905@s.whatsapp.net", "status_message_id": "3EB0STATUS", "type": "text", "content": "Aberto hoje", "timestamp": 1741361650 } } ``` #### status.viewed Campos: `phone`, `from`, `status_message_id`, `whatsapp_status_id`, `status_type`, `receipt_type`, `timestamp`. ```json { "type": "status.viewed", "data": { "phone": "554137984905", "from": "554137984905@s.whatsapp.net", "status_message_id": "550e8400-e29b-41d4-a716-446655440000", "whatsapp_status_id": "3EB0STATUS", "status_type": "image", "receipt_type": "read", "timestamp": 1741361650 } } ``` #### status.reaction Campos: `phone`, `from`, `push_name`, `status_message_id`, `whatsapp_status_id`, `status_type`, `emoji`, `removed`, `timestamp`. ```json { "type": "status.reaction", "data": { "phone": "554137984905", "from": "554137984905@s.whatsapp.net", "push_name": "Joao", "status_message_id": "550e8400-e29b-41d4-a716-446655440000", "whatsapp_status_id": "3EB0STATUS", "status_type": "image", "emoji": "👍", "removed": false, "timestamp": 1741361650 } } ``` #### status.reply Campos: `phone`, `from`, `push_name`, `status_message_id`, `whatsapp_status_id`, `status_type`, `reply_message_id`, `whatsapp_reply_id`, `reply_type`, `content`, `media_id`, `timestamp`. ```json { "type": "status.reply", "data": { "phone": "554137984905", "from": "554137984905@s.whatsapp.net", "push_name": "Joao", "status_message_id": "550e8400-e29b-41d4-a716-446655440000", "whatsapp_status_id": "3EB0STATUS", "reply_message_id": "550e8400-e29b-41d4-a716-446655440001", "whatsapp_reply_id": "3EB0REPLY", "reply_type": "text", "content": "Gostei", "timestamp": 1741361650 } } ``` #### message.pin_sent Campos: `chat`, `phone`, `target_message_id`, `target_whatsapp_message_id`, `envelope_whatsapp_message_id`, `pin`, `duration_seconds`, `target_from_me`, `timestamp`. ```json { "type": "message.pin_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "target_message_id": "550e8400-e29b-41d4-a716-446655440000", "target_whatsapp_message_id": "3EB0TARGET", "envelope_whatsapp_message_id": "3EB0PIN", "pin": true, "duration_seconds": 86400, "target_from_me": true, "timestamp": 1741361650 } } ``` #### message.poll_vote_sent Campos: `chat`, `phone`, `poll_message_id`, `poll_whatsapp_message_id`, `envelope_whatsapp_message_id`, `selected_options`, `poll_from_me`, `timestamp`. ```json { "type": "message.poll_vote_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "poll_message_id": "550e8400-e29b-41d4-a716-446655440000", "poll_whatsapp_message_id": "3EB0POLL", "envelope_whatsapp_message_id": "3EB0VOTE", "selected_options": ["sim"], "poll_from_me": false, "timestamp": 1741361650 } } ``` #### message.star_sent Campos: `chat`, `phone`, `message_id`, `whatsapp_message_id`, `starred`, `from_me`, `timestamp`. ```json { "type": "message.star_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "message_id": "550e8400-e29b-41d4-a716-446655440000", "whatsapp_message_id": "3EB0ABC123", "starred": true, "from_me": true, "timestamp": 1741361650 } } ``` #### message.read_sent Campos: `chat`, `phone`, `message_ids`, `whatsapp_message_ids`, `count`, `timestamp`. ```json { "type": "message.read_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "message_ids": ["550e8400-e29b-41d4-a716-446655440000"], "whatsapp_message_ids": ["3EB0ABC123"], "count": 1, "timestamp": 1741361650 } } ``` #### instance.no_proxy_blocked Campos: `reason`, `detail`, `blocked_at`, `desired_state`. ```json { "type": "instance.no_proxy_blocked", "data": { "reason": "proxy_required", "detail": "instance has no proxy binding", "blocked_at": "2026-05-21T12:00:00Z", "desired_state": "DISCONNECTED" } } ``` #### contact.block_sent Campos: `phone`, `jid`, `timestamp`. ```json { "type": "contact.block_sent", "data": { "phone": "554137984905", "jid": "554137984905@s.whatsapp.net", "timestamp": 1741361650 } } ``` #### contact.unblock_sent Campos: `phone`, `jid`, `timestamp`. ```json { "type": "contact.unblock_sent", "data": { "phone": "554137984905", "jid": "554137984905@s.whatsapp.net", "timestamp": 1741361650 } } ``` #### contact.presence_subscribed Campos: `phone`, `jid`, `timestamp`. ```json { "type": "contact.presence_subscribed", "data": { "phone": "554137984905", "jid": "554137984905@s.whatsapp.net", "timestamp": 1741361650 } } ``` #### profile.name_changed_sent Campos: `new_name`, `timestamp`. ```json { "type": "profile.name_changed_sent", "data": { "new_name": "Empresa LTDA", "timestamp": 1741361650 } } ``` #### profile.picture_changed_sent Campos: `bytes_uploaded`, `timestamp`. ```json { "type": "profile.picture_changed_sent", "data": { "bytes_uploaded": 43821, "timestamp": 1741361650 } } ``` #### chat.unread_sent Campos: `chat`, `phone`, `timestamp`. ```json { "type": "chat.unread_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "timestamp": 1741361650 } } ``` #### chat.archive_sent Campos: `chat`, `phone`, `archived`, `timestamp`. Disparado quando você arquiva/desarquiva um chat. É uma mutação de AppState que não gera self-echo, então este é o único sinal de que você fez a ação. ```json { "type": "chat.archive_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "archived": true, "timestamp": 1741361650 } } ``` #### chat.pin_sent Campos: `chat`, `phone`, `pinned`, `timestamp`. Disparado quando você fixa/desfixa um chat. ```json { "type": "chat.pin_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "pinned": true, "timestamp": 1741361650 } } ``` #### chat.mute_sent Campos: `chat`, `phone`, `muted`, `mute_end` (opcional), `timestamp`. Disparado quando você muta/desmuta um chat. `mute_end` é o unix de expiração do mute (ausente ou `0` = indefinido / desmutado). ```json { "type": "chat.mute_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "muted": true, "mute_end": 1741448050, "timestamp": 1741361650 } } ``` #### chat.clear_sent Campos: `chat`, `phone`, `timestamp`. Disparado quando você limpa as mensagens de um chat MANTENDO o chat na lista (distinto de `chat.delete_sent`). ```json { "type": "chat.clear_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "timestamp": 1741361650 } } ``` #### chat.delete_sent Campos: `chat`, `phone`, `timestamp`. Disparado quando você deleta um chat (remove da lista de conversas). ```json { "type": "chat.delete_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "timestamp": 1741361650 } } ``` #### chat.disappearing_timer_sent Campos: `chat`, `phone`, `duration_seconds`, `timestamp`. Disparado quando você define o timer de mensagens temporárias do chat. `duration_seconds` ∈ `0` (desligado) | `86400` (24h) | `604800` (7 dias) | `7776000` (90 dias). ```json { "type": "chat.disappearing_timer_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "duration_seconds": 604800, "timestamp": 1741361650 } } ``` #### status.reply_sent Campos: `chat`, `phone`, `status_message_id`, `type`, `timestamp`. Disparado quando você responde ao status de um contato (lado executor; a contraparte inbound no autor do status é `status.reply`). `chat` = JID 1:1 do autor (onde a resposta cai); `status_message_id` = id WhatsApp do status respondido; `type` ∈ `text` | `image` | `video` | `sticker`. ```json { "type": "status.reply_sent", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "status_message_id": "3EB0STATUS", "type": "text", "timestamp": 1741361650 } } ``` #### presence.broadcast_sent Campos: `presence_type`, `chat`, `phone`, `timestamp`. ```json { "type": "presence.broadcast_sent", "data": { "presence_type": "available", "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "timestamp": 1741361650 } } ``` #### group.create_sent Campos: `group_jid`, `name`, `participants_jids`, `participant_count`, `timestamp`. ```json { "type": "group.create_sent", "data": { "group_jid": "120363012345678901@g.us", "name": "Equipe Vendas", "participants_jids": ["554137984905@s.whatsapp.net"], "participant_count": 1, "timestamp": 1741361650 } } ``` #### group.name_changed_sent Campos: `group_jid`, `new_name`, `timestamp`. ```json { "type": "group.name_changed_sent", "data": { "group_jid": "120363012345678901@g.us", "new_name": "Novo Nome", "timestamp": 1741361650 } } ``` #### group.description_changed_sent Campos: `group_jid`, `new_description`, `timestamp`. ```json { "type": "group.description_changed_sent", "data": { "group_jid": "120363012345678901@g.us", "new_description": "Nova descricao", "timestamp": 1741361650 } } ``` #### group.picture_changed_sent Campos: `group_jid`, `bytes_uploaded`, `timestamp`. ```json { "type": "group.picture_changed_sent", "data": { "group_jid": "120363012345678901@g.us", "bytes_uploaded": 43821, "timestamp": 1741361650 } } ``` #### group.participants_add_sent Campos: `group_jid`, `action`, `requested_jids`, `successful_jids`, `failed_jids`, `timestamp`. ```json { "type": "group.participants_add_sent", "data": { "group_jid": "120363012345678901@g.us", "action": "add", "requested_jids": ["554137984905@s.whatsapp.net"], "successful_jids": ["554137984905@s.whatsapp.net"], "failed_jids": [], "timestamp": 1741361650 } } ``` #### group.participants_remove_sent Campos iguais a `group.participants_add_sent`, com `action="remove"`. ```json { "type": "group.participants_remove_sent", "data": { "group_jid": "120363012345678901@g.us", "action": "remove", "requested_jids": ["554137984905@s.whatsapp.net"], "successful_jids": ["554137984905@s.whatsapp.net"], "failed_jids": [], "timestamp": 1741361650 } } ``` #### group.participants_promote_sent Campos iguais a `group.participants_add_sent`, com `action="promote"`. ```json { "type": "group.participants_promote_sent", "data": { "group_jid": "120363012345678901@g.us", "action": "promote", "requested_jids": ["554137984905@s.whatsapp.net"], "successful_jids": ["554137984905@s.whatsapp.net"], "failed_jids": [], "timestamp": 1741361650 } } ``` #### group.participants_demote_sent Campos iguais a `group.participants_add_sent`, com `action="demote"`. ```json { "type": "group.participants_demote_sent", "data": { "group_jid": "120363012345678901@g.us", "action": "demote", "requested_jids": ["554137984905@s.whatsapp.net"], "successful_jids": ["554137984905@s.whatsapp.net"], "failed_jids": [], "timestamp": 1741361650 } } ``` #### group.participants_approved_sent Campos iguais a `group.participants_add_sent`, com `action="approve"`. ```json { "type": "group.participants_approved_sent", "data": { "group_jid": "120363012345678901@g.us", "action": "approve", "requested_jids": ["554137984905@s.whatsapp.net"], "successful_jids": ["554137984905@s.whatsapp.net"], "failed_jids": [], "timestamp": 1741361650 } } ``` #### group.participants_rejected_sent Campos iguais a `group.participants_add_sent`, com `action="reject"`. ```json { "type": "group.participants_rejected_sent", "data": { "group_jid": "120363012345678901@g.us", "action": "reject", "requested_jids": ["554137984905@s.whatsapp.net"], "successful_jids": ["554137984905@s.whatsapp.net"], "failed_jids": [], "timestamp": 1741361650 } } ``` #### group.settings_sent Campos: `group_jid`, `announce`, `locked`, `timestamp`. ```json { "type": "group.settings_sent", "data": { "group_jid": "120363012345678901@g.us", "announce": true, "locked": false, "timestamp": 1741361650 } } ``` #### group.invite_link_revoked_sent Campos: `group_jid`, `new_link`, `timestamp`. ```json { "type": "group.invite_link_revoked_sent", "data": { "group_jid": "120363012345678901@g.us", "new_link": "https://chat.whatsapp.com/AbCdEfGhIjKl", "timestamp": 1741361650 } } ``` #### group.join_sent Campos: `group_jid`, `invite_code`, `timestamp`. ```json { "type": "group.join_sent", "data": { "group_jid": "120363012345678901@g.us", "invite_code": "AbCdEfGhIjKl", "timestamp": 1741361650 } } ``` #### group.leave_sent Campos: `group_jid`, `timestamp`. ```json { "type": "group.leave_sent", "data": { "group_jid": "120363012345678901@g.us", "timestamp": 1741361650 } } ``` #### instance.deleted Campos: `instance_id`, `phone`, `timestamp`. ```json { "type": "instance.deleted", "data": { "instance_id": "84c2e480-...", "phone": "554137984905", "timestamp": 1741361650 } } ``` #### history.import_started Campos: `oldest_message_id`, `limit`, `timestamp`. ```json { "type": "history.import_started", "data": { "oldest_message_id": "3EB0OLD", "limit": 500, "timestamp": 1741361650 } } ``` #### history.import_completed Campos: `imported_count`, `limit`, `duration_ms`, `timestamp`. ```json { "type": "history.import_completed", "data": { "imported_count": 342, "limit": 500, "duration_ms": 1842, "timestamp": 1741361650 } } ``` #### message.pinned Campos: `chat`, `phone`, `sender`, `is_group`, `envelope_whatsapp_message_id`, `target_whatsapp_message_id`, `target_from_me`, `sender_timestamp_ms`, `timestamp`. ```json { "type": "message.pinned", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "sender": "554137984905@s.whatsapp.net", "is_group": false, "envelope_whatsapp_message_id": "3EB0PIN", "target_whatsapp_message_id": "3EB0TARGET", "target_from_me": true, "timestamp": 1741361650 } } ``` #### message.unpinned Mesmo payload de `message.pinned`, para unpin. ```json { "type": "message.unpinned", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "sender": "554137984905@s.whatsapp.net", "is_group": false, "envelope_whatsapp_message_id": "3EB0PIN", "target_whatsapp_message_id": "3EB0TARGET", "target_from_me": true, "timestamp": 1741361650 } } ``` #### message.poll_vote_received Campos: `chat`, `phone`, `voter`, `is_group`, `envelope_whatsapp_message_id`, `poll_whatsapp_message_id`, `poll_from_me`, `timestamp`. ```json { "type": "message.poll_vote_received", "data": { "chat": "120363012345678901@g.us", "phone": "554137984905", "voter": "554137984905@s.whatsapp.net", "is_group": true, "envelope_whatsapp_message_id": "3EB0VOTE", "poll_whatsapp_message_id": "3EB0POLL", "poll_from_me": false, "timestamp": 1741361650 } } ``` #### message.reaction_received Campos: `chat`, `phone`, `sender`, `is_group`, `envelope_whatsapp_message_id`, `target_whatsapp_message_id`, `target_from_me`, `emoji`, `timestamp`. ```json { "type": "message.reaction_received", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "sender": "554137984905@s.whatsapp.net", "is_group": false, "envelope_whatsapp_message_id": "3EB0REACTION", "target_whatsapp_message_id": "3EB0TARGET", "target_from_me": true, "emoji": "👍", "timestamp": 1741361650 } } ``` #### message.product_received Campos: `chat`, `phone`, `sender`, `is_group`, `envelope_whatsapp_message_id`, `product_id`, `business_owner_jid`, `title`, `timestamp`. ```json { "type": "message.product_received", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "sender": "554137984905@s.whatsapp.net", "is_group": false, "envelope_whatsapp_message_id": "3EB0PROD", "product_id": "prod_123", "business_owner_jid": "554100000000@s.whatsapp.net", "title": "Produto", "timestamp": 1741361650 } } ``` #### message.order_received Campos: `chat`, `phone`, `sender`, `is_group`, `envelope_whatsapp_message_id`, `order_id`, `merchant_jid`, `item_count`, `total_amount_1000`, `currency`, `timestamp`. ```json { "type": "message.order_received", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "sender": "554137984905@s.whatsapp.net", "is_group": false, "envelope_whatsapp_message_id": "3EB0ORDER", "order_id": "order_123", "merchant_jid": "554100000000@s.whatsapp.net", "item_count": 2, "total_amount_1000": 9900000, "currency": "BRL", "timestamp": 1741361650 } } ``` #### message.decrypt_retry_pending Campos: `chat`, `phone`, `sender`, `is_group`, `whatsapp_message_id`, `is_unavailable`, `unavailable_type`, `decrypt_fail_mode`, `started_at`, `retry_deadline`, `retry_window_seconds`. ```json { "type": "message.decrypt_retry_pending", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "sender": "554137984905@s.whatsapp.net", "is_group": false, "whatsapp_message_id": "3EB0BAD", "is_unavailable": true, "unavailable_type": "unavailable", "decrypt_fail_mode": "retry", "started_at": "2026-05-21T12:00:00Z", "retry_deadline": "2026-05-21T12:02:00Z", "retry_window_seconds": 120 } } ``` #### session.recreated Campos: `peer_jid`, `phone`, `retry_count`, `reason`, `timestamp`. ```json { "type": "session.recreated", "data": { "peer_jid": "554137984905@s.whatsapp.net", "phone": "554137984905", "retry_count": 2, "reason": "retry_count_high", "timestamp": 1741361650 } } ``` #### message.delivery_failed Campos: `chat`, `phone`, `message_id`, `whatsapp_message_id`, `idempotency_key`, `task_id`, `message_type`, `reason`, `last_error`, `attempt_count`, `timestamp`. ```json { "type": "message.delivery_failed", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "message_id": "550e8400-e29b-41d4-a716-446655440000", "idempotency_key": "send-001", "task_id": "asynq:task:abc", "message_type": "text", "reason": "retry_exhausted", "last_error": "timeout", "attempt_count": 5, "timestamp": 1741361650 } } ``` #### instance.qr_scanned Campos: `phone`, `device_jid`, `business_name`, `platform_name`, `timestamp`. ```json { "type": "instance.qr_scanned", "data": { "phone": "554137984905", "device_jid": "554137984905:1@s.whatsapp.net", "business_name": "Empresa LTDA", "platform_name": "smbi", "timestamp": 1741361650 } } ``` #### instance.proxy_changed Campos: `old_ip`, `new_ip`, `reason`, `old_city`, `new_city`, `provider`, `timestamp`. ```json { "type": "instance.proxy_changed", "data": { "old_ip": "198.51.100.10", "new_ip": "198.51.100.11", "reason": "health_replace", "old_city": "br-saopaulo", "new_city": "br-saopaulo", "provider": "brightdata", "timestamp": 1741361650 } } ``` #### contact.temperature_changed Campos: `remote_jid`, `phone`, `old_tier`, `new_tier`, `old_score`, `new_score`, `direction`, `trigger`, `timestamp`. ```json { "type": "contact.temperature_changed", "data": { "remote_jid": "554137984905@s.whatsapp.net", "phone": "554137984905", "old_tier": "cold", "new_tier": "warm", "old_score": 25, "new_score": 42, "direction": "inbound", "trigger": "message.received", "timestamp": 1741361650 } } ``` #### anti_spam.blocked Campos: `remote_jid`, `phone`, `rule`, `detail`, `message_type`, `bypass_available`, `bypass_quota_left`, `content_hash`, `timestamp`. ```json { "type": "anti_spam.blocked", "data": { "remote_jid": "554137984905@s.whatsapp.net", "phone": "554137984905", "rule": "duplicate_content", "detail": "same content sent in rolling 24h", "message_type": "text", "bypass_available": true, "bypass_quota_left": 2, "content_hash": "sha256:abc", "timestamp": 1741361650 } } ``` #### whatsapp.policy_warning Campos: `warning_code`, `category`, `message`, `expires_at`, `severity_hint`, `recommended_action`, `timestamp`. ```json { "type": "whatsapp.policy_warning", "data": { "warning_code": "rate_limit", "category": "anti_abuse", "message": "rate limit warning", "expires_at": "2026-05-21T13:00:00Z", "severity_hint": "warn", "recommended_action": "slow_down", "timestamp": 1741361650 } } ``` --- ### Persistencia de eventos Eventos são persistidos no histórico operacional do tenant quando passam pelo pipeline de realtime/webhooks. Eventos transientes (chat.*, label.*, privacy.update, blocklist.update, sync.*, mídia.retry_result, message.starred, message.deleted_for_me, message.undecryptable, message.decrypt_recovered, message.decrypt_lost) não criam/alteram linhas de mensagem em `messages` — são entregues via webhook/SSE/WebSocket e consultáveis pelo histórico de eventos quando habilitado. **Ciclo de vida da mensagem na tabela `messages`:** ``` queued → sent → delivered → read ↓ deleted (se revogada) ``` | Campo | Preenchido quando | |-------|-------------------| | `queued_at` | Mensagem entra na fila | | `sent_at` | Enviada com sucesso ao WhatsApp | | `delivered_at` | Destinatário recebeu (receipt type=delivered) | | `read_at` | Destinatário leu (receipt type=read) | | `deleted_at` | Mensagem apagada (ProtocolMessage REVOKE) | | `failed_at` | Falha no envio | **Tabelas de eventos:** | Tabela | Evento | Descricao | |--------|--------|-----------| | `messages` | message.received, message.sent, message.delivered, message.read, message.played, message.deleted, message.edited | Todas as mensagens inbound/outbound com tracking completo | | `call_logs` | call.received, call.accepted, call.rejected, call.ended, call.group_offer | Histórico de chamadas | | `group_events` | group.update, group.joined | Auditoria de mudancas em grupos | | `contact_events` | contact.update, contact.identity_changed, contact.sync | Mudancas em contatos | --- ## 16. Eventos em Tempo Real (SSE/WebSocket) Receba eventos em tempo real sem polling. Ideal para dashboards e chatbots. ### GET /v1/instances/{instanceId}/events (SSE) Server-Sent Events stream. **Auth:** Header `Authorization: Bearer ` ou `X-API-Key: ` **Empresa ativa (mesma regra da [seção 1 - Autenticação](#1-autenticação)):** com JWT, a cada conexão o servidor consulta o master DB: empresa inexistente → `403` (`company not found`), suspensa → `403` (`company suspended`), falha de banco nessa consulta → `503` (`service unavailable`). Com API key, empresa suspensa → `403`; token inválido → `401`. **Query Parameters:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `events` | string | Filtro de eventos (separados por virgula). Ex: `message.received,connection.update` | | `last_event_id` | string | Cursor opcional para replay curto. Se informado, o servidor reenviara eventos buffered posteriores a esse ID | **Resume/Replays:** o SSE também aceita o header `Last-Event-ID`. O hub mantem um buffer em memória com os **100** eventos mais recentes por instância para retomada após desconexoes curtas. **Limite por instância:** máximo de **20** conexões SSE/WebSocket simultaneas por instância. Excesso retorna `429 Too Many Requests`. **Formato dos eventos:** ``` event: connected data: {"instance_id": "84c2e480-..."} event: message.received id: evt_1234 data: {"instance_id": "84c2e480-...", "message": {...}} : heartbeat ``` **Evento live-only do Audit Timeline:** quando o worker grava uma linha em `stealth_audit_events`, ele pública um evento SSE/WS `stealth.audit_event` no mesmo canal Redis por instância. Esse evento existe para refresh imediato da UI e **não** entra em webhooks nem em `GET /events/history`. **Payload `stealth.audit_event`:** ```json { "id": 42, "instance_id": "84c2e480-...", "event_type": "connection.opened", "event_data": { "proxy_city": "sao-paulo", "spec": "hello_chrome_120" }, "occurred_at": "2026-04-20T22:30:00Z", "created_at": "2026-04-20T22:30:00Z" } ``` **Vocabulario controlado de `event_type` (v1):** - `fingerprint.assigned` - `fingerprint.rotated` - `warmup.phase_changed` - `warmup.bypass_granted` - `warmup.bypass_expired` - `connection.opened` - `connection.closed` - `connection.banned` - `hygiene.applied` - `humanize.applied` - `stealth.inbound_first.blocked` - `stealth.app_version_canary_started` - `stealth.app_version_fleet_applied` - `stealth_transport.debug_enabled` - `stealth_transport.debug_renewed` - `stealth_transport.debug_expired` **Notas warmup:** - `warmup.phase_changed` e gravado de forma transacional junto com o `UPDATE tenant_instances.warmup_phase`. - `warmup.bypass_granted` e emitido por `POST /v1/admin/instances/{instanceId}/warmup/bypass` e carrega `reason`, `ticket_id`, `token` e `expires_at` dentro de `event_data`. - `hygiene.applied` e emitido uma vez por alteração bem-sucedida (`push_name`, `photo`, `status`) e carrega `type` + `value` dentro de `event_data`. **Exemplo com curl:** ```bash curl -N \ -H "Authorization: Bearer eyJ..." \ "https://api.catcher.one/v1/instances/84c2e480/events?events=message.received,connection.update" ``` **Exemplo com JavaScript (`fetch` streaming):** ```javascript const response = await fetch( 'https://api.catcher.one/v1/instances/84c2e480/events?events=message.received', { headers: { Authorization: `Bearer ${token}` } } ); // Leia response.body como ReadableStream e parseie o protocolo SSE. ``` --- ### GET /v1/instances/{instanceId}/events/history Histórico de eventos persistidos com paginação baseada em cursor. **Auth:** Bearer JWT ou API Key **Query Parameters:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `limit` | int | Máximo de eventos (1-200, padrão 50) | | `before` | string | Cursor: `event_id` para buscar eventos anteriores (DESC) | | `after` | string | Cursor: `event_id` para buscar eventos posteriores (ASC, catch-up) | | `since` | string | Timestamp RFC3339 para buscar eventos a partir de (ASC) | | `types` | string | Filtro por tipo(s) de evento, separados por virgula. Ex: `message.received,message.sent` | | `contains` | string | Busca por substring em qualquer campo principal do evento: `event_id`, `type`, `instance_id`, `timestamp` e no JSON `data` (chaves e valores). Ex: `contains=ABC123` encontra todos eventos que referenciam o WhatsApp message ID `ABC123`; `contains=message.sent` filtra por tipo; `contains=2026-04-23` encontra eventos daquela data | **Resposta 200:** ```json { "events": [ { "event_id": "ca5e3b45-055a-4ccc-be37-cb9d76d25887", "type": "message.received", "instance_id": "be23db3e-900d-4194-98ca-7a1deb2157a5", "timestamp": "2026-04-04T17:34:55.965Z", "data": { "message_ids": ["ABC123"], "chat": "5511999999999@s.whatsapp.net", "from": "5511999999999@s.whatsapp.net", "type": "text", "content": "Ola!" } } ], "has_more": true } ``` **Correlação de eventos com mensagens:** Use `contains` com o `whatsapp_id` da mensagem para encontrar todos os eventos relacionados (received, sent, delivered, read, deleted, edited). **Busca paginada:** quando `contains` estiver presente, a paginação (`before` / `after`) contínua sendo aplicada sobre o conjunto filtrado. Exemplo: `limit=50&contains=oi` retorna os 50 eventos mais recentes que contenham `oi`; o próximo `before=` retorna os 50 matches seguintes, não apenas os próximos 50 eventos brutos. --- ### GET /v1/instances/{instanceId}/events/stats Estatísticas agregadas dos eventos de uma instância. **Auth:** Bearer JWT ou API Key **Resposta 200:** ```json { "total_count": 1523, "count_by_type": { "message.received": 450, "message.sent": 320, "message.delivered": 310, "message.read": 280 }, "latest_timestamp": "2026-04-04T17:34:55.965Z" } ``` --- ### GET /v1/instances/{instanceId}/ws (WebSocket) Conexão WebSocket para eventos em tempo real. **Auth:** Header `Authorization` ou `X-API-Key` (mesma regra do SSE). **Query Parameters:** `events` e `last_event_id`. A autenticação não aceita mais `token` em query string. **Comportamento:** - Upgrade para WebSocket (101 Switching Protocols) - Somente leitura (servidor envia, cliente recebe) - Ping a cada 30s para manter a conexão - Mensagens em formato JSON - Replay inicial opcional a partir de `last_event_id` - Se o limite de 20 subscribers por instância for excedido, a API responde `429` **antes** do upgrade **Exemplo com Node.js (`ws` com headers):** ```javascript const WebSocket = require('ws'); const ws = new WebSocket( 'wss://api.catcher.one/v1/instances/84c2e480/ws?events=message.received', { headers: { Authorization: `Bearer ${token}` } } ); ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log('Evento:', data.event, data); }; ``` --- ## 17. Administração (Superadmin) Rotas exclusivas para administradores da plataforma. ### GET /v1/admin/system Informações do sistema. **Auth:** Superadmin **Resposta 200:** ```json { "uptime_seconds": 86400, "goroutines": 42, "memory": { "alloc_mb": 128, "sys_mb": 256, "gc_cycles": 50, "heap_objects": 100000, "heap_inuse_mb": 100 }, "go_version": "go1.22.0", "num_cpu": 4 } ``` --- ### GET /v1/admin/companies Lista todas as empresas da plataforma. **Auth:** Superadmin **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 20 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "id": 1, "name": "Minha Empresa", "status": "active", "created_at": "2026-03-01T00:00:00Z" } ], "total": 10, "page": 1, "limit": 20 } ``` --- ### GET /v1/admin/companies/{companyId}/stats Estatísticas de uma empresa. **Auth:** Superadmin **Resposta 200:** ```json { "company_id": 1, "name": "Minha Empresa", "status": "active", "instances": 3, "messages": 15000, "webhooks": 2 } ``` --- ### POST /v1/admin/companies/{companyId}/suspend Suspende uma empresa. Todos os acessos são bloqueados. **Auth:** Superadmin **Resposta 200:** ```json { "company_id": 1, "status": "suspended" } ``` --- ### POST /v1/admin/companies/{companyId}/unsuspend Reativa uma empresa suspensa. **Auth:** Superadmin **Resposta 200:** ```json { "company_id": 1, "status": "active" } ``` --- ### GET /v1/admin/instances Lista todas as instâncias de todas as empresas. **Auth:** Superadmin **Observação:** sem rate limit adicional no grupo admin; a protecao e feita por autenticação JWT + `RequireSuperadmin()`. **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 20 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "company_id": 1, "instance": { "id": "84c2e480-...", "name": "...", "status": "CONNECTED" } } ], "total": 25, "page": 1, "limit": 20 } ``` --- ### POST /v1/admin/instances/{instanceId}/disconnect Forca a desconexao de uma instância. **Auth:** Superadmin **Resposta 200:** ```json { "instance_id": "84c2e480-...", "disconnected": true } ``` --- ### POST /v1/admin/instances Cria uma nova instância para qualquer empresa. **Auth:** Superadmin **Request:** ```json { "company_id": 14, "name": "Nova instancia" } ``` **Resposta 201:** Objeto de instância criado. --- ### POST /v1/admin/instances/{instanceId}/connect Conecta uma instância (gera QR code). **Auth:** Superadmin **Resposta 200:** Objeto de instância com `qr_base64` se disponível. --- ### POST /v1/admin/instances/{instanceId}/restart Desconecta e reconecta uma instância, gerando nova sessão QR. **Auth:** Superadmin **Resposta 200:** Objeto de instância com `qr_base64` se disponível. --- ### POST /v1/admin/instances/{instanceId}/logout Faz logout e apaga a sessão. Requer novo pareamento. **Auth:** Superadmin **Resposta 200:** Objeto de instância atualizado. --- ### DELETE /v1/admin/instances/{instanceId} Remove uma instância permanentemente. **Auth:** Superadmin **Resposta 204:** Sem corpo. --- ### GET /v1/admin/instances/{instanceId}/qr Retorna o QR code atual de qualquer instância. **Auth:** Superadmin **Resposta 200:** Mesmo formato de `GET /v1/instances/{instanceId}/qr`. --- ### GET /v1/admin/instances/{instanceId}/qr/stream Stream SSE de QR codes de qualquer instância. Mesmo formato de `GET /v1/instances/{instanceId}/qr/stream`. **Auth:** Superadmin --- ### POST /v1/admin/instances/{instanceId}/pairing-code Gera código de pareamento para qualquer instância. Mesmo formato de `POST /v1/instances/{instanceId}/pairing-code`. **Auth:** Superadmin --- ### GET /v1/admin/queue Estatísticas globais de todas as filas. **Auth:** Superadmin **Resposta 200:** ```json { "queues": [ { "name": "instance:84c2e480", "size": 100, "pending": 5, "active": 1, "completed": 90, "failed": 2, "scheduled": 1, "retry": 1 } ] } ``` --- ### GET /v1/admin/queue/archived Lista tasks que falharam permanentemente (DLQ — Dead Letter Queue). **Auth:** Superadmin **Query params:** - `queue` — Nome da fila (default: `default`) - `limit` — Max items retornados (default: 25, max: 100) **Resposta 200:** ```json { "queue": "default", "count": 2, "archived": [ { "id": "task_abc123", "type": "msg:send_text", "queue": "default", "max_retry": 3, "retried": 3, "last_failed_at": "2026-03-22T10:30:00Z", "last_err": "send failed: not connected", "instance_id": "84c2e480-...", "company_id": 1 } ] } ``` --- ### GET /v1/admin/logs/files Lista arquivos de log disponíveis no diretorio configurado. **Auth:** Superadmin **Resposta 200:** ```json { "dir": "./data/logs", "files": [ { "name": "biazap.log", "size": 9371 }, { "name": "biazap-2026-03-21.log.gz", "size": 2048 } ] } ``` --- ### GET /v1/admin/logs Retorna linhas filtradas de um arquivo de log (tail com filtros). **Auth:** Superadmin **Query params:** | Param | Default | Descricao | |-------|---------|-----------| | `file` | `biazap.log` | Nome do arquivo (sem path) | | `limit` | `200` | Max linhas retornadas (max 5000) | | `skip` | `0` | Pular N linhas do final | | `level` | — | Filtrar por level (error, warn, info, debug) | | `contains` | — | Busca texto livre em qualquer campo | | `instance_id` | — | Filtrar por instance_id | **Resposta 200:** ```json { "file": "biazap.log", "total": 1234, "showing": 10, "skip": 0, "limit": 10, "lines": [ "{\"level\":\"info\",\"time\":\"2026-03-22T13:41:48Z\",\"message\":\"text message sent\"}" ] } ``` **Exemplos de uso:** ```bash # Ultimas 50 linhas GET /v1/admin/logs?limit=50 # Apenas erros GET /v1/admin/logs?level=error&limit=100 # Filtrar por instancia GET /v1/admin/logs?instance_id=84c2e480-xxxx&limit=200 # Busca texto livre GET /v1/admin/logs?contains=rate+limited&limit=50 ``` --- ### POST /v1/admin/broadcast Envia uma notificação para todos os webhooks ativos da plataforma. **Auth:** Superadmin **Request:** ```json { "message": "Manutencao programada para hoje as 22h", "type": "system.broadcast" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `message` | string | sim | Conteúdo da notificação | | `type` | string | não | Tipo do evento. Padrão: `system.broadcast` | **Resposta 200:** ```json { "broadcast": true, "webhooks_notified": 15 } ``` --- ### GET /v1/admin/tokens Lista todos os tokens ativos da plataforma. **Auth:** Superadmin **Query Parameters:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `company_id` | int | Filtrar por empresa (opcional) | **Resposta 200:** ```json [ { "id": 15, "company_id": 14, "company_name": "EJ ESTETICA", "label": "producao", "last4": "f4eb", "role": "owner", "created_at": "2026-03-07T23:42:37Z" } ] ``` --- ### POST /v1/admin/tokens Cria um token de API para qualquer empresa. **Auth:** Superadmin **Request:** ```json { "company_id": 14, "label": "integracao-v2", "role": "owner" } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|:-----------:|-----------| | `company_id` | int | sim | ID da empresa | | `label` | string | sim | Nome identificador | | `role` | string | não | `owner`, `admin`, `agent`. Padrão: `owner` | **Resposta 201:** ```json { "token_id": 16, "token": "bza_xxxx...xxxx", "label": "integracao-v2", "role": "owner", "last4": "xxxx", "company_id": 14, "created_at": "2026-03-30T14:00:00Z" } ``` > **Importante:** O token so e exibido uma vez. Salve-o em local seguro. --- ### DELETE /v1/admin/tokens/{tokenId} Revoga qualquer token da plataforma. **Auth:** Superadmin **Resposta 204:** Sem corpo. --- ### GET /v1/admin/webhooks/{webhookId}/logs Lista delivery logs de qualquer webhook. **Auth:** Superadmin **Query Parameters:** | Parametro | Tipo | Descricao | |-----------|------|-----------| | `page` | int | Página (default: 1) | | `limit` | int | Itens por página (default: 50, max: 100) | | `success` | string | Filtrar: `true` ou `false` | **Resposta 200:** ```json { "data": [ { "id": 123, "webhook_id": 12, "event_id": "evt-uuid", "event_type": "message.delivered", "payload": "{}", "payload_raw": "{\"event_type\":\"message.delivered\",...}", "status_code": 500, "response": "{\"success\":false,\"error\":\"...\"}", "attempt": 1, "success": false, "created_at": "2026-03-30T15:42:01Z" } ], "total": 27, "page": 1, "limit": 50 } ``` > **Nota:** O campo `payload_raw` contem o corpo completo enviado ao webhook. So disponível para entregas falhadas (deliveries com sucesso tem `payload_raw` limpo após 48h pelo cleanup). --- ### GET /v1/admin/webhooks Lista todos os webhooks da plataforma com enriquecimento de empresa e instância. **Auth:** Superadmin **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 20 | Itens por página (max 100) | **Resposta 200:** ```json { "data": [ { "id": 12, "url": "https://example.com/webhook", "events": "message.received,message.sent", "active": true, "instance_id": "", "instance_name": "", "company_id": 14, "company_name": "EJ ESTETICA", "created_at": "2026-03-07T10:00:00Z", "updated_at": "2026-03-07T10:00:00Z" } ], "total": 10, "page": 1, "limit": 20 } ``` --- ### GET /v1/admin/webhooks/{webhookId} Retorna um webhook por ID. **Auth:** Superadmin **Resposta 200:** Mesmo formato do item acima. --- ### PATCH /v1/admin/webhooks/{webhookId} Atualiza campos de qualquer webhook. **Auth:** Superadmin **Request (todos opcionais):** ```json { "url": "https://novo-endpoint.com/webhook", "events": "message.received", "active": true, "instance_id": "84c2e480-..." } ``` **Resposta 200:** Webhook atualizado. --- ### DELETE /v1/admin/webhooks/{webhookId} Remove qualquer webhook. **Auth:** Superadmin **Resposta 204:** Sem corpo. --- ### GET /v1/admin/webhooks/metrics Metricas de saúde por webhook (entregas, falhas, taxa de sucesso) em uma janela de tempo. **Auth:** Superadmin **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `hours` | int | 24 | Janela de tempo em horas | **Resposta 200:** ```json { "period_hours": 2, "webhooks": [ { "webhook_id": 21, "url": "https://example.com/webhook", "company_id": 14, "company_name": "EJ ESTETICA", "instance_id": "84c2e480-...", "instance_name": "EJ Whats", "total": 37, "successes": 37, "failures": 0, "success_rate": 100 } ] } ``` > `company_name` e `instance_name` são enriquecidos a partir das tabelas `companies` e `tenant_instances`. Se o webhook não está filtrado por instância, `instance_id` e `instance_name` ficam vazios. --- ### GET /v1/admin/delivery-logs Lista delivery logs de todos os webhooks da plataforma com paginação e filtros. **Auth:** Superadmin **Query Parameters:** | Parametro | Tipo | Padrão | Descricao | |-----------|------|--------|-----------| | `page` | int | 1 | Página | | `limit` | int | 50 | Itens por página (max 100) | | `success` | bool | - | Filtrar por sucesso/falha | | `event_type` | string | - | Filtrar por tipo de evento | | `since` | string | - | Filtrar desde data (RFC3339) | | `webhook_id` | int | - | Filtrar por webhook ID | **Resposta 200:** ```json { "data": [ { "id": 1234, "webhook_id": 21, "event_id": "3c726dc7-...", "event_type": "message.received", "status_code": 200, "success": true, "error_class": "", "created_at": "2026-04-12T17:00:00Z", "webhook_url": "https://example.com/webhook", "company_name": "EJ ESTETICA", "company_id": 14 } ], "total": 85, "page": 1, "limit": 50 } ``` --- ### POST /v1/admin/webhooks/retry-batch Reenvia entregas falhadas em lote (máximo 500 por chamada). **Auth:** Superadmin **Request:** ```json { "log_ids": [1234, 1235], "webhook_id": 21, "event_type": "message.received", "since": "2026-04-12T00:00:00Z" } ``` > Todos os campos são opcionais. `log_ids` tem prioridade — se fornecido, os demais filtros são ignorados. Sem `log_ids`, os filtros combinam para selecionar as falhas a reenviar. **Resposta 200:** ```json { "requeued": 3, "skipped": 1, "errors": [] } ``` --- --- > **Nota:** Os endpoints do Security Dashboard (`/v1/admin/security/dashboard`, `/threats`, `/ip-reputation`, `/hackers`) foram removidos. A deteccao de ameacas agora e gerenciada pela biblioteca compartilhada Sentinel (`github.com/ronszcka/sentinel`), que possui sua própria interface de administração. --- ## 17.W Stealth Observability (Superadmin) Scaffold inicial para os dashboards administrativos de fingerprint/warmup. Os endpoints abaixo já existem e mantem shape estável mesmo antes das tasks que populam os campos de fingerprint/warmup. ### GET /v1/admin/fingerprint-audit Resumo frota-wide de diversidade de plataforma/spec/cidade + violacoes de coerencia. **Auth:** Superadmin **Resposta 200:** ```json { "total_instances": 0, "by_platform": {}, "by_utls_spec": {}, "by_city": {}, "coherence_violations": { "city_mismatch_ddd": 0, "push_name_equals_api": 0, "mcc_equals_000": 0, "platform_spec_mismatch": 0 }, "warmup_distribution": {} } ``` **Notas:** - `total_instances` e contado na frota ativa. - Os mapas podem vir vazios no bootstrap inicial; a shape não muda. - O frontend admin em `/platform/fingerprint-audit` consome este payload. ### GET /v1/admin/warmup-audit Resumo frota-wide da distribuição de warmup, instâncias perto do daily cap e próximas promocoes. **Auth:** Superadmin **Resposta 200:** ```json { "total_instances": 0, "by_phase": {}, "near_daily_cap": [], "next_promotions": [] } ``` **Notas:** - O endpoint existe antes dos produtores finais de warmup para permitir integração antecipada do dashboard. - O frontend admin em `/platform/warmup-audit` consome este payload. ### Fingerprint uTLS Pool (Superadmin) Profiles deste pool são atribuidos somente a instâncias novas. Instâncias já pareadas nunca são mutadas, e a fonte de verdade em runtime contínua sendo o campo tenant `fingerprint_utls_spec`. Built-ins entram como `staging`; WA Probe e o único gate que promove um profile para `active`. #### GET /v1/admin/fingerprint-pool/profiles Retorna todos os profiles uTLS do master DB com status, peso, último probe e estado de burn. **Auth:** Superadmin #### PATCH /v1/admin/fingerprint-pool/profiles/{profileName} Atualiza campos operacionais de um profile. Permite `weight` entre 0 e 1000 e `status` igual a `staging`, `deprecated` ou `burned`. `active` não e aceito neste endpoint: WA Probe contínua sendo o único gate de promoção para active. **Auth:** Superadmin **Body:** ```json { "status": "deprecated", "weight": 3 } ``` #### POST /v1/admin/fingerprint-pool/profiles/seed-builtins Seed idempotente dos profiles compilados no binario atual. Em 2026-05-08, o builtin disponível e `safari-mac-17`. **Auth:** Superadmin #### POST /v1/admin/fingerprint-pool/profiles/{profileName}/wa-probe/{proxyId} Executa um WhatsApp QR Probe descartavel através do proxy selecionado usando o profile uTLS escolhido. Não pareia instância de cliente. **Auth:** Superadmin **Metricas relacionadas:** `biazap_fingerprint_utls_pool_active_size`, `biazap_fingerprint_utls_assignments_total`, `biazap_fingerprint_utls_probe_total`, `biazap_fingerprint_utls_burn_total`. ### GET /v1/admin/app-version Retorna o estado singleton do rollout de AppVersion mantido pelo worker no master DB. O worker consulta o upstream do whatsmeow a cada 14 dias, válida o semver detectado e apenas faz stage; a aplicação na frota depende de aprovacao explicita por superadmin. **Auth:** Superadmin **Resposta 200:** ```json { "current_catalog_version": "2.3000.1034566421", "detected_upstream_version": "2.3001.1", "staged_pending_approval_version": "2.3001.1", "canary_rollout": { "active": true, "started_at": "2026-04-21T15:00:00Z", "instance_ids": [ "inst-a", "inst-b", "inst-c", "inst-d", "inst-e" ], "fleet_applied_at": null }, "last_fetch_at": "2026-04-21T14:00:00Z" } ``` **Notas:** - `staged_pending_approval_version` fica vazio quando o upstream não detecta um valor mais novo que o catalogo atual. - `canary_rollout.active=true` significa que a versão staged já foi aprovada e está observando 5 instâncias por 72h antes do fleet apply. - O worker persiste o estado em `app_version_states` (singleton `id=1`) para sobreviver a restart. ### POST /v1/admin/app-version/approve Aprova a versão staged e inicia o rollout canary em 5 instâncias conectadas escolhidas aleatoriamente. Não recebe body. **Auth:** Superadmin **Resposta 200:** ```json { "current_catalog_version": "2.3000.1034566421", "detected_upstream_version": "2.3001.1", "staged_pending_approval_version": "2.3001.1", "canary_rollout": { "active": true, "started_at": "2026-04-21T15:00:00Z", "instance_ids": [ "inst-a", "inst-b", "inst-c", "inst-d", "inst-e" ], "fleet_applied_at": null }, "last_fetch_at": "2026-04-21T14:00:00Z" } ``` **Resposta 400:** ```json { "error": "no staged app version pending approval" } ``` **Notas:** - O canary escreve `fingerprint_app_version` apenas nas 5 instâncias selecionadas; o restante da frota contínua com `current_catalog_version`. - Depois de 72h sem falha observada, o worker promove a staged para `current_catalog_version`, aplica na frota toda e emite `stealth.app_version_fleet_applied`. - Se o fetch upstream falhar ou for rejeitado nas sanity checks, o worker mantem o catalogo atual, loga WARN e incrementa `biazap_appversion_fetch_failed_total`. ### POST /v1/admin/instances/{instanceId}/warmup/bypass Concede um bypass temporario do warmup v2 para uma instância especifica. O worker passa a ignorar os gates de `warmup_phase`, daily cap e janela horaria enquanto a chave Redis do bypass estiver válida. **Auth:** Superadmin **Body JSON:** ```json { "reason": "sla unblock", "ticket_id": "SUP-123", "auto_expire_at": "2026-04-22T12:00:00Z" } ``` **Campos:** - `reason` obrigatório. - `ticket_id` obrigatório. - `auto_expire_at` opcional. Quando omitido, a API usa TTL padrão de 24h. **Resposta 200:** ```json { "instance_id": "84c2e480-...", "token": "0f3f6f15-3fa2-46f0-b7a7-d7790f9e5e36", "expires_at": "2026-04-22T12:00:00Z" } ``` **Notas:** - O grant grava uma linha em `stealth_audit_events` com `event_type = "warmup.bypass_granted"`. - O bypass e rate-limited por instância no Redis para evitar uso repetitivo sem triagem humana. - O valor retornado em `token` e apenas auditoria/operação; o gate do worker válida a chave Redis por instância. ### POST /v1/admin/instances/{instanceId}/hygiene/apply Forca a aplicação imediata dos defaults de hygiene na instância conectada e persiste `hygiene_skip=false` para os próximos connects. **Auth:** Superadmin **Body JSON:** nenhum. **Resposta 200:** ```json { "instance_id": "84c2e480-...", "company_id": 1, "hygiene_skip": false } ``` **Notas:** - O endpoint reutiliza o mesmo applier deterministico do worker (`FingerprintSeed -> push_name/avatar/status`). - O endpoint e rate-limited por instância no Redis para evitar uso repetitivo sem triagem humana. - A chamada falha se a instância não tiver sessão WhatsApp conectada no worker; nesse caso `hygiene_skip` permanece inalterado. - Cada alteração aplicada gera `stealth.audit_event` / `hygiene.applied` com `event_data.type` em `push_name`, `photo` ou `status`. ### Regras de alerta seedadas No primeiro boot, a API passa a semear também estas `AlertRule` padrão para o modulo Stealth: - `FingerprintPlatformDiversityLow` - `UTLSSpecsTooFew` - `ConnectionBurst` - `WarmupReceiveOnlyBacklog` - `BanRateSpike` - `ProxyPoolCityStarved` - `StealthDebugModeStuck` - `StealthAuditTableUnbounded` --- ## 17.X Proxy Pool (Bright Data ISP 1:1) — Superadmin Cada instância WhatsApp roda atrás de um IP residencial dedicado (Bright Data, zona `isp_br`) — 1 IP por instância, sem compartilhamento entre clientes. Endpoints abaixo expoem o pool; clientes normais não veem. Quando `STEALTH_IP_COHERENCE_ENABLED=true`, o worker reavalia o proxy no `instance:connect`: deriva `DDD -> city` pelo catalogo compartilhado de fingerprint, reaproveita um IP `available` saudável (`last_health_ok=true`) na cidade correspondente ou provisiona 1 novo IP no Bright Data nessa cidade. DDD desconhecido ou telefone ausente cai para `br-saopaulo`. O script noturno `scripts/proxy-pool-warm-cities.sh` preaquece 5 IPs livres nas 10 maiores cidades BR (`br-saopaulo`, `br-rio-de-janeiro`, `br-belo-horizonte`, `br-curitiba`, `br-porto-alegre`, `br-brasilia`, `br-salvador`, `br-recife`, `br-fortaleza`, `br-belem`) sem ultrapassar `BRIGHT_DATA_MAX_POOL_SIZE`. ### GET /v1/admin/proxy-pool Lista IPs do pool com totais por status e custo mensal. **Auth:** Superadmin **Query:** `status` (available|assigned|banned|cooldown|decommissioned), `page`, `limit` **Resposta 200:** ```json { "data": [ { "id": 1, "provider": "bright-data", "zone": "isp_br", "ip_address": "200.160.45.35", "city": "br-saopaulo", "isp": "", "host": "brd.superproxy.io", "port": 33335, "status": "assigned", "assigned_company_id": 3, "assigned_instance": "9b1f8d20-312c-41fd-88d5-0de4648feefc", "assigned_at": "2026-04-17T00:15:00Z", "last_health_check": "2026-04-17T00:20:12Z", "last_health_ok": true, "ban_count": 0, "cost_usd_per_month": 4, "created_at": "2026-04-16T23:23:08Z", "updated_at": "2026-04-16T23:23:19Z" } ], "total": 1, "page": 1, "limit": 50, "counts_by_status": { "assigned": 1 }, "monthly_cost_usd": 4, "zone": "isp_br", "min_free_target": 2, "max_pool_size": 50 } ``` **Nota de seguranca:** `username` e `password` NUNCA são retornados. Apenas host/porta públicos. ### GET /v1/admin/proxy-pool/{proxyId} Detalhe de um único IP. Mesmo shape do item em `data` acima. ### POST /v1/admin/proxy-pool/sync Reconcilia o pool local com a zone `isp_br` no Bright Data. Insere IPs novos, marca `decommissioned` os que sumiram no provedor, não mexe em `assigned`. **Resposta 200:** ```json { "inserted": ["200.160.45.35"], "decommissioned": null, "unchanged": 0, "total": 1 } ``` ### POST /v1/admin/proxy-pool/provision Aloca N novos IPs dedicados no Bright Data ($4/IP/mes). Respeita `BRIGHT_DATA_MAX_POOL_SIZE` — se atingido, provisiona zero e retorna. **Body:** `{ "count": 2 }` **Resposta 200:** `{ "requested": 2, "provisioned": 2 }` ### DELETE /v1/admin/proxy-pool/{proxyId} Remove o IP permanentemente (no provedor E no pool local). 409 se o IP está em `assigned` — libere a instância primeiro. ### POST /v1/admin/proxy-pool/{proxyId}/health Roda um health check ao vivo: conecta via HTTP CONNECT ao `ipinfo.io` e confere que o exit IP bate com o esperado. **Resposta 200:** ```json { "id": 1, "ok": true, "ip_address": "200.160.45.35", "last_health_check": "2026-04-17T00:20:12Z" } ``` ### POST /v1/admin/proxy-pool/{proxyId}/wa-probe Roda um **WhatsApp QR Probe** sincrono através do IP. Abre uma sessão disposable (whatsmeow + tmpdir SQLite + `SetProxyAddress`), espera o primeiro QR code (sem parear), e fecha. Detecta IPs queimados antes de qualquer cliente ver: `ConnectFailure 401/402/403/406`, `StreamError 401/403`, `TemporaryBan` -> verdict=`burned`. Latencia típica 10-15s. Pre-requisito: `WORKER_RPC_URL` apontando para o worker e `WA_PROBE_AT_PROVISION` em `enabled` ou `manual_only` no env. IP precisa estar `status=available` (proibido tocar IPs já atribuidos a um cliente real). Verdict + signal + latency são persistidos em `proxy_ips.last_wa_probe_*` e emitem um row no `proxy_ip_events` (eternal log). **Resposta 200:** ```json { "verdict": "ok", "signal": "qr_emitted", "latency_ms": 9420, "ip_address": "200.160.45.35", "provider": "bright-data" } ``` Verdicts: - `ok` — Meta aceitou ClientPayload e emitiu QR. IP fica no pool. - `burned` — Meta rejeitou (ban-y signal). Operador pode descomissionar via DELETE. - `inconclusive` — timeout / ClientOutdated / 5xx. IP fica como está; tentar de novo. Erros: - 503 `WA_PROBE_DISABLED` — RPC não wired (faltou `WORKER_RPC_URL`). - 503 `PROXY_POOL_DISABLED` — Bright Data não configurado. - 502 `WA_PROBE_FAILED` — RPC falhou (worker down, network). Pre-release gate (provision + sync) chama o mesmo caminho via `SetValidateMetaReachabilityFunc` no `cmd/biazap-api/main.go` — quando `WA_PROBE_AT_PROVISION=enabled`, todo IP novo passa por essa verificação antes de entrar como `available`. Burned verdicts no gate disparam `Decommission` automático no provider; verdicts `inconclusive` deixam o IP entrar com `last_wa_probe_ok=NULL` (re-probe manual depois). ### POST /v1/admin/proxy-pool/{proxyId}/cooldown/release Forca um IP em `cooldown` de volta para `available`. Usado quando você está confiante de que o ban foi transiente. **Resposta 200:** `{ "released": true, "id": 1 }` ### Ciclo de vida do IP ``` available --> assigned --> (banido ou desatribuido) ^ | | v +-- cooldown --- (72h) | +-- decommissioned (removido para sempre) ``` - `available`: livre, pode ser alocado na próxima criação de instância - `assigned`: em uso 1:1 com uma instância - `banned`: reservado para casos manuais - `cooldown`: 72h fora do pool; auto-retorna a `available` - `decommissioned`: removido no provedor, mantido no DB para auditoria > **Nota operacional:** o loop de health pong hoje persiste evidência (`proxy_health_events`, `last_health_*`) e incrementa metricas, mas não move automaticamente o IP para `cooldown` quando detecta mismatch. O cooldown contínua sendo uma ação administrativa/manual. ### Metricas Prometheus | Metrica | Labels | Descricao | |---|---|---| | `biazap_proxy_pool_total` | `status` | Gauge: contagem por status | | `biazap_proxy_pool_size` | `city` | Gauge: tamanho atual do pool por cidade (exclui `decommissioned`) | | `biazap_proxy_pool_cost_usd_monthly` | — | Gauge: custo mensal em USD (soma `cost_usd_per_month` de IPs não-decommissioned) | | `biazap_proxy_alloc_total` | `result` (success/pool_exhausted/bd_failure) | Counter de tentativas de alocação | | `biazap_proxy_provision_total` | `result` (success/failure/capped/bad_ip/exhausted_retries) | Counter de provisionamentos via BD API. `bad_ip` = IP provisionado mas rejeitado pela validação BR (discard no BD imediato). `exhausted_retries` = 5 tentativas consecutivas retornaram IPs não-BR, provisionamento abortado (operator precisa investigar). | | `biazap_proxy_health_failures_total` | — | Counter de falhas em health check | | `biazap_proxy_bans_total` | — | Counter de bans manuais (`MarkBanned`) | | `biazap_fingerprint_city_total` | `city` | Counter: cidade efetivamente entregue por `AllocateForPhone` / rebind coerente por DDD | ### Env vars relacionadas - `BRIGHT_DATA_API_KEY`, `BRIGHT_DATA_CUSTOMER_ID`, `BRIGHT_DATA_ZONE`, `BRIGHT_DATA_ZONE_PASSWORD` — credenciais provedor - `BRIGHT_DATA_HOST` (default `brd.superproxy.io`), `BRIGHT_DATA_PORT` (default 33335) - `BRIGHT_DATA_DEFAULT_CITY` (default `br-saopaulo`) - `BRIGHT_DATA_MIN_FREE_POOL` (default 2) — auto top-up mantem pelo menos esses IPs livres - `BRIGHT_DATA_MAX_POOL_SIZE` (default 100) — teto duro de IPs para controlar custo - `STEALTH_UTLS_ENABLED` (default `false`) — ativa os tres HTTP clients de sessão com uTLS no `instance:connect`; quando `false`, o worker usa o caminho legado com `SetProxyAddress` - `STEALTH_FAIL_CLOSED` (default `false`) — recusa `instance:connect` e `GET /v1/instances/{instanceId}/stealth/verify` sem proxy dedicado em vez de cair para egress direto - `STEALTH_BEHAVIOR_ENABLED` (default `false`) — instala o scheduler central de restore/reconnect/post-pair/auto-presence e ativa os hooks ADR-009 no fork do whatsmeow; quando `false`, o worker preserva os sleeps e backoffs legados - `STEALTH_DNS_HARDENED_ENABLED` (default `false`) — troca o resolver dos validators SSRF (`ValidateMediaURL` e webhook URL validation) para DNS-over-TLS em `1.1.1.1:853` e faz o client default de upload por `media_url` usar o mesmo resolver em vez do OS resolver - `STEALTH_IP_COHERENCE_ENABLED` (default `false`) — rebind por `DDD -> city` no `instance:connect`; quando `false`, o connect reutiliza a alocação legada ### Resposta da instância com proxy Todas as respostas de instância (`GET /v1/instances`, `GET /v1/instances/{id}`) passam a incluir o campo `proxy` quando o pool está ativo: ```json { "id": "9b1f8d20-312c-41fd-88d5-0de4648feefc", "name": "ProxyTest1", "status": "CONNECTED", "proxy": { "ip_address": "200.160.45.35", "city": "br-saopaulo", "isp": "", "assigned_at": "2026-04-17T00:15:00Z" }, "created_at": "..." } ``` **Erros:** quando o pool está vazio e `MaxPoolSize` foi atingido, `POST /v1/instances` retorna `503` com `error_code=PROXY_POOL_EXHAUSTED`. --- ## 17.Y Proxy Audit — Auditoria Contínua de Disciplina de Proxy/VPN (Superadmin) A auditoria de proxy executa um conjunto fixo de checks a cada 5 minutos para provar o invariante "todo tráfego Meta/WhatsApp sai pelo IP dedicado da instância". Cada execução persiste uma linha em `proxy_audit_runs` + uma linha por finding em `proxy_audit_findings`. As metricas Prometheus são atualizadas em sincronia e as regras de alerta padrão (`AlertRule`) são semeadas no primeiro boot. **Checks executados:** | Slug | Descricao | Severidade em caso de falha | |------|-----------|------------------------------| | `pong_mismatch` | Mismatches ou falhas em `proxy_health_events` na última hora | error | | `assigned_ip_unhealthy` | IPs status=assigned com `last_health_ok=false` | error | | `instance_without_proxy` | Instâncias CONNECTED sem `proxy_ip_id` | error (pool on) / warn (pool off) | | `stale_pong` | IPs assigned sem pong OK nos últimos 11min | error | **Estados possíveis de um run:** - `ok=true, warnings=0` → "all checks ok" - `ok=true, warnings>0` → tolerado (ex.: instância legacy sem binding) - `ok=false` → violação do invariante, qualquer finding com severity=error ### GET /v1/admin/proxy-audit/runs Lista os runs mais recentes (sem findings). Para o histórico na UI. **Auth:** Superadmin **Query:** - `limit`: int, default 50, max 200 **Response (200):** ```json { "data": [ { "id": 1234, "trigger": "scheduled", "started_at": "2026-04-18T12:00:00Z", "finished_at": "2026-04-18T12:00:01Z", "duration_ms": 234, "ok": true, "checks": 4, "failures": 0, "warnings": 0, "summary": "all checks ok" } ], "total": 1, "limit": 50 } ``` ### GET /v1/admin/proxy-audit/latest Retorna o run mais recente COM findings. Quando nenhum run foi executado ainda, responde `{ "status": "pending_first_run" }` (HTTP 200) para o dashboard renderizar o estado vazio. **Auth:** Superadmin **Response (200):** ```json { "id": 1234, "trigger": "scheduled", "started_at": "2026-04-18T12:00:00Z", "finished_at": "2026-04-18T12:00:01Z", "duration_ms": 234, "ok": false, "checks": 4, "failures": 1, "warnings": 0, "summary": "failed: pong_mismatch — 2 pong failures/mismatches in last hour", "findings": [ { "check": "pong_mismatch", "severity": "error", "count": 2, "message": "2 pong failures/mismatches in last hour", "detail": [ { "proxy_ip_id": 7, "expected": "1.2.3.4", "observed": "9.9.9.9", "ok": true, "match": false, "at": "2026-04-18T11:55:00Z" } ] } ] } ``` ### GET /v1/admin/proxy-audit/runs/{runId} Retorna um run especifico COM findings. **Auth:** Superadmin ### POST /v1/admin/proxy-audit/run Dispara um run sincrono agora mesmo. Response identica a `/latest`. Registrado em `audit_logs` (`action=proxy_audit.run`). **Auth:** Superadmin ### Metricas Prometheus relacionadas | Metrica | Tipo | Alerta configurado | |---------|------|--------------------| | `biazap_proxy_audit_runs_total{result,trigger}` | counter | — | | `biazap_proxy_audit_failures_total{check,severity}` | counter | via AlertRule `proxy_audit_last_run_ok < 1` | | `biazap_proxy_audit_last_run_timestamp_seconds` | gauge | dashboard | | `biazap_proxy_audit_last_run_ok` | gauge (0/1) | AlertRule built-in | | `biazap_instances_without_proxy_binding` | gauge | AlertRule `> 0` | | `biazap_instances_connected_direct_total` | counter | AlertRule `> 0` | | `biazap_proxy_health_pong_mismatch_total` | counter | AlertRule `> 0` | ### Gate no /ready Quando o pool está habilitado (`BRIGHT_DATA_*` configurado), `GET /ready` retorna `503 degraded` se: - o último run tem `ok=false`, ou - o último run e mais antigo que 15min (auditor travado) Use o `/health` para checagem de liveness (nunca bloqueia em proxy state) e `/ready` como gate de tráfego. --- ## 17.Z whatsmeow Sync — Atualização Automatizada da Fork (Superadmin) Job diário (systemd timer `biazap-whatsmeow-claude-update.timer`, 04:30 UTC) que mantem `third_party/whatsmeow/` sincronizada com `tulir/whatsmeow main` sem perder os patches da nossa fork. Pipeline: workspace isolado em git worktree -> agente Claude Code headless faz merge + audit + build + test + patch pin + smoke isolado -> se smoke green e agente confirma `Auto promote: yes`, orquestrador faz ff merge em main + push + deploy worker. Caso contrario, branch fica pushed para review humano (status=pending_review). Detalhes do design: `.claude/rules/whatsmeow-claude-update.md`. Endpoint base: `/v1/admin/whatsmeow-sync/*`. Todos requerem JWT de superadmin. Retorna o envelope padrão de erros (error_code, trace_id). ### GET /v1/admin/whatsmeow-sync/runs Lista execuções passadas (mais recentes primeiro, padrão 50). Query: `limit` (1..200). Resposta 200: ```json { "data": [ { "id": 7, "trigger": "scheduled", "started_at": "2026-04-21T04:30:00.000Z", "finished_at": "2026-04-21T04:34:12.500Z", "duration_ms": 252500, "before_sha": "9e93da2...", "after_sha": "5b88861...", "upstream_sha": "5b88861...", "tag_name": "biazap-sync-2026-04-21-043000", "baked_before": "2.3000.1034566421", "baked_after": "2.3000.1037753511", "status": "success", "worker_deployed": true, "worker_health_ok": true, "e2e_test_ok": true, "log_monitor_ok": true, "push_ok": true, "archive_pre_path": "/usr/workspace/biazap-archives/whatsmeow/2026-04-21-043000-pre.tar.gz", "archive_post_path": "/usr/workspace/biazap-archives/whatsmeow/2026-04-21-043000-post.tar.gz", "log_path": "/usr/workspace/biazap/data/logs/whatsmeow-sync/2026-04-21-043000.log", "created_at": "2026-04-21T04:30:00.100Z" } ], "total": 1, "limit": 50 } ``` ### GET /v1/admin/whatsmeow-sync/runs/{id} Detalhe de uma execução especifica. Mesmo shape de item em /runs, com campos extra quando aplicavel: `failed_phase`, `failure_reason`, `conflict_files` (array). 404 se `id` não existe. ### GET /v1/admin/whatsmeow-sync/latest Última execução (qualquer status). Se nenhuma rodou ainda: ```json { "status": "pending_first_run" } ``` ### GET /v1/admin/whatsmeow-sync/status Resumo p/ o hero card do dashboard — mistura estado live do git com histórico do DB: ```json { "current_baked": "2.3000.1037753511", "current_fork_sha": "5b88861abc...", "last_run_ok": true, "last_run_status": "success", "last_run_id": 7, "last_success_at": "2026-04-21T04:34:12Z", "last_success_tag_name": "biazap-sync-2026-04-21-043000", "last_known_upstream_sha": "5b88861abc...", "hours_since_last_run": 6, "hours_since_last_success": 6, "pending_first_run": false, "timer_healthy": true, "timer_health_message": "" } ``` `timer_healthy=false` se `hours_since_last_success > 30` (yellow) ou `> 48` (red). Usado pelo card como sinal principal. ### POST /v1/admin/whatsmeow-sync/run Dispara manualmente. Resposta 202: ```json { "run_id": 8, "status": "queued", "unit": "biazap-whatsmeow-claude-update-manual-8", "poll": "/v1/admin/whatsmeow-sync/runs/8" } ``` Rejeita com 409 `SYNC_IN_PROGRESS` se já existe um run com `status=running`. Pre-aloca a linha no DB antes de spawn. ### POST /v1/admin/whatsmeow-sync/runs/{id}/rollback Reverte para o archive preservado de uma execução histórica. Requer que a target run tenha `archive_post_path` existente no disco. Resposta 202: ```json { "run_id": 9, "target_id": 7, "status": "queued", "unit": "biazap-whatsmeow-claude-update-rollback-9", "poll": "/v1/admin/whatsmeow-sync/runs/9" } ``` Erros: - `404` — target run não existe - `400 NO_ARCHIVE` — target não gravou archive_post (ex. skipped_up_to_date) - `410 ARCHIVE_MISSING` — tarball foi removido do disco (retencao 30d) - `409 SYNC_IN_PROGRESS` — outro run em andamento ### Metricas - `biazap_whatsmeow_sync_runs_total{status,trigger}` — counter - `biazap_whatsmeow_sync_last_run_ok` — gauge 0/1 - `biazap_whatsmeow_sync_last_success_timestamp_seconds` — gauge - `biazap_whatsmeow_sync_days_behind_upstream` — gauge - `biazap_whatsmeow_sync_phase_duration_seconds{phase}` — histogram ### Arquivos no disco - Logs: `/usr/workspace/biazap/data/logs/whatsmeow-sync/YYYY-MM-DD-HHMMSS.log` - Archives: `/usr/workspace/biazap-archives/whatsmeow/YYYY-MM-DD-HHMMSS-{pre,post}.tar.gz` - Retencao de archives: 30 dias (configuravel via `WHATSMEOW_SYNC_ARCHIVE_DAYS`) - Retencao de rows em `whatsmeow_sync_runs`: permanente no momento (eventual cleanup via TODO). --- ## 17.AA Connectivity Admin — Diagnóstico por Instância (Superadmin) Expansão da superficie de `/v1/admin/connectivity/*` com duas rotas novas que casam com o console "Connectivity" no frontend. Os endpoints originais (`metrics` e `qr-diagnostics`) permanecem válidos para consumidores antigos. - Auth: JWT de superadmin em todos os endpoints. - Envelope de erro padrão (`error_code`, `message`, `trace_id`). - Shapes de janela aceitas: `15m`, `1h`, `6h`, `24h`, `7d`. Valores fora desse conjunto caem no default de cada endpoint. ### GET /v1/admin/connectivity/instances Rollup por instância com contagens agregadas de eventos de conexão numa janela configuravel. Usado pelo console de connectivity para render do grid principal com filtros + ordenacao. **Query params:** | Param | Tipo | Default | Significado | |---|---|---|---| | `window` | `15m\|1h\|6h\|24h\|7d` | `24h` | Janela de agregacao dos contadores. | | `status` | CSV de status (`CONNECTED,DISCONNECTED,...`) | — | Restringe `instances[]` aos status informados. Não afeta `aggregate`. | | `company_id` | uint | — | Limita a uma empresa. | | `q` | string | — | Busca parcial case-insensitive em `name`, `phone`, `external_id`. | | `limit` | int | 50 (max 200) | Paginação. | | `offset` | int | 0 | Paginação. | | `sort` | `activity_desc\|activity_asc\|name_asc\|flap_desc` | `activity_desc` | Ordenacao após filtros. | **Contadores `counts_window` (por instância):** | Campo | Fonte | |---|---| | `connect_start` | `connection.diagnostic` com `step=connect_start` | | `connect_cooldown` | `connection.diagnostic` com `step=connect_cooldown` | | `connection_connected` | `connection.update` com `status=connected` | | `connection_disconnected` | `connection.update` com `status=disconnected` | | `connection_replaced` | `connection.update` com `status=replaced` (total — inclui self-caused) | | `stream_replaced_self` | `connection.diagnostic` com `step=stream_replaced_self` | | `flap_detected` | `connection.diagnostic` com `step=flap_detected` | | `websocket_closed` | `connection.diagnostic` com `step=websocket_closed` | | `keepalive_restored` | `connection.{update,diagnostic}` | | `keepalive_timeout` | `connection.{update,diagnostic}` | | `logged_out` | `connection.update` com `status=logged_out` | | `banned` | `instance.banned` OU `connection.update` com `status=banned\|permanently_banned` | | `qr_emitted` | `connection.diagnostic` com `step=qr_emitted` | | `messages_sent` | `messages` com `direction=outbound AND created_at >= window_start` | | `messages_received` | `messages` com `direction=inbound AND created_at >= window_start` | **Aggregate (fleet-wide):** | Campo | Significado | |---|---| | `total_instances` | Total pos-filtro (antes de paginação). | | `by_status` | Map com as chaves `CONNECTED`, `DISCONNECTED`, `QR_PENDING`, `CONNECTING`, `BANNED`, `TIMED_OUT`, `CREATED` sempre presentes (0 quando vazio). | | `connectivity_rate` | `connected / total_instances * 100` (arredondado 2 casas). | | `flap_detected_in_window` | Soma dos `flap_detected` em toda a frota. | | `stream_replaced_self_in_window` | Soma dos `stream_replaced_self`. | | `stream_replaced_genuine_in_window` | `connection.update status=replaced` menos o número de self-caused. Nunca negativo. | | `ban_in_window` | Bans observados na janela (via `instance.banned` + `connection.update status=banned`). | | `logged_out_in_window` | Logouts observados na janela. | **Resposta 200 (exemplo abreviado):** ```json { "window": "24h", "window_seconds": 86400, "total": 42, "returned": 10, "aggregate": { "total_instances": 42, "by_status": {"CONNECTED":38,"DISCONNECTED":2,"QR_PENDING":0,"CONNECTING":1,"BANNED":0,"TIMED_OUT":0,"CREATED":1}, "connectivity_rate": 90.48, "flap_detected_in_window": 1, "stream_replaced_self_in_window": 0, "stream_replaced_genuine_in_window": 0, "ban_in_window": 0, "logged_out_in_window": 0 }, "instances": [ { "id": "f35157b4-46ae-4bdb-8f51-4870bcb5bf9c", "company_id": 9, "company_name": "Catcher", "company_slug": "catcher-tenant-1", "name": "Catcher", "phone": "554137984905", "status": "CONNECTED", "desired_state": "CONNECTED", "tier": "healthy", "connected_at": "2026-04-23T13:22:22Z", "last_seen": "2026-04-23T13:22:22Z", "last_activity_at": "2026-04-23T13:27:11Z", "disconnected_at": null, "connected_duration_seconds": 1248, "proxy": { "id": 11, "ip_address": "200.239.204.138", "city": "br-saopaulo", "zone": "isp_br", "last_health_ok": true, "last_health_check": "2026-04-23T13:25:00Z" }, "last_error": "", "ban_expiry": null, "counts_window": { "connect_start": 2, "connect_cooldown": 0, "connection_connected": 2, "connection_disconnected": 1, "connection_replaced": 0, "stream_replaced_self": 0, "flap_detected": 0, "websocket_closed": 1, "keepalive_restored": 0, "keepalive_timeout": 0, "logged_out": 0, "banned": 0, "qr_emitted": 0, "messages_sent": 127, "messages_received": 84 }, "last_event": { "type": "connection.update", "status_or_step": "connected", "at": "2026-04-23T13:22:22Z" } } ] } ``` O campo `tier` usa a mesma função `health.EvaluateTier` da rota de `/v1/admin/instance-health` (healthy, stale, degraded, offline, critical_offline, intentional, pending, banned). ### GET /v1/admin/connectivity/instances/{id}/timeline Timeline cronologica (DESC) de eventos de conexão para UMA instância. Usado pelo drawer de diagnostico e pelo reporter de incidentes do console de connectivity. **Query params:** | Param | Tipo | Default | Significado | |---|---|---|---| | `window` | `15m\|1h\|6h\|24h\|7d` | `1h` | Janela do range de eventos. | | `types` | CSV de event types | `connection.diagnostic,connection.update,instance.offline,instance.recovered,instance.banned,instance.critical_offline` | Restringe o feed. | | `limit` | int | 200 (max 1000) | Paginação hard-capped. | **Resposta 200:** ```json { "instance": { "id": "f35157b4-46ae-4bdb-8f51-4870bcb5bf9c", "company_id": 9, "company_name": "Catcher", "name": "Catcher", "phone": "554137984905", "status": "CONNECTED", "desired_state": "CONNECTED", "tier": "healthy", "connected_at": "2026-04-23T13:22:22Z", "last_seen": "2026-04-23T13:22:22Z", "last_activity_at": "2026-04-23T13:27:11Z", "disconnected_at": null }, "window": "1h", "window_seconds": 3600, "total": 12, "events": [ { "event_id": "e5b4c57a-...", "type": "connection.diagnostic", "timestamp": "2026-04-23T13:22:18.526Z", "summary": "connect_start", "level": "info", "step": "connect_start", "message": "Iniciando conexao", "data": {"step":"connect_start","level":"info","message":"Iniciando conexao","timestamp":1713879738526} }, { "event_id": "4fda3eec-...", "type": "connection.update", "timestamp": "2026-04-23T13:22:22.178Z", "summary": "connected", "level": "success", "data": {"status":"connected"} } ] } ``` **Derivacao de `summary`/`level`:** - `connection.diagnostic`: `summary=step`, `level` vem da própria row. - `connection.update`: `summary=status`, `level` inferido (connected -> success; disconnected/replaced/banned/keepalive_timeout -> warning; logged_out/connect_failure/stream_error -> error). - `instance.offline` -> warning, `instance.recovered` -> success, `instance.banned` / `instance.critical_offline` -> error. **404** se o `instanceId` não existe em nenhum tenant DB ativo. --- ## 18. Uso e Limites ### GET /v1/usage/{companyId} Retorna o consumo atual e limites do plano da empresa. **Auth:** Todos autenticados **Resposta 200:** ```json { "company_id": 1, "plan_name": "Pro", "limits": { "max_instances": 5, "messages_per_day": 10000, "max_webhooks": 10, "max_users": 20 }, "usage": { "instances": 2, "messages_today": 1500, "webhooks": 3, "users": 5 } } ``` --- ## 19. Códigos de Erro ### Formato unificado de resposta Todos os erros seguem o mesmo envelope JSON, com `error_code` estável para handling tipado e `trace_id` para casamento exato com o log do backend: ```json { "error_code": "INSTANCE_NOT_CONNECTED", "message": "instance is not connected (status: DISCONNECTED)", "trace_id": "2a62142c-f55f-4db4-8c7b-060018e3ef48", "error": "instance is not connected (status: DISCONNECTED)" } ``` | Campo | Descricao | |---|---| | `error_code` | Código estável em `SCREAMING_SNAKE_CASE`. Use isso pra lógica condicional no cliente. | | `message` | Mensagem legivel para humanos. | | `trace_id` | UUID que identifica unicamente o request. Também disponível no header de resposta `X-Trace-ID`. Use para correlacionar com `GET /v1/admin/logs?trace_id=`. | | `error` | Campo legacy, identico a `message`. Mantido por uma release para integradores antigos. Migre para `message`. | O header `X-Trace-ID` está exposto via CORS e pode ser lido pelo navegador (`error.response.headers['x-trace-id']`). ### Códigos HTTP | Código | error_code padrão | Descricao | |--------|-------------------|-----------| | `400` | `BAD_REQUEST` | Corpo inválido, campos faltando ou inválidos | | `401` | `UNAUTHORIZED` / `TOKEN_INVALID` | Token ausente, expirado ou inválido | | `403` | `FORBIDDEN` / `COMPANY_SUSPENDED` / `INSUFFICIENT_PERMISSIONS` | Sem permissão, empresa suspensa | | `404` | `NOT_FOUND` / `INSTANCE_NOT_FOUND` / `MESSAGE_NOT_FOUND` / `WEBHOOK_NOT_FOUND` | Recurso não encontrado | | `409` | `CONFLICT` / `DUPLICATE_INSTANCE_NAME` / `INSTANCE_NOT_CONNECTED` | Estado conflitante | | `422` | `UNPROCESSABLE_ENTITY` | Dados válidos mas não processaveis | | `429` | `RATE_LIMITED` / `ACCOUNT_LOCKED` | Rate limit excedido. Verifique os headers `Retry-After` e `X-RateLimit-*` | | `500` | `INTERNAL_ERROR` | Erro interno do servidor | | `503` | `SERVICE_UNAVAILABLE` | Serviço degradado (MySQL/Redis/S3, lookup de empresa no JWT) | ### Códigos de erro estáveis A lista canonica vive em `internal/api/error_codes.go`. Os principais: | `error_code` | Quando ocorre | |---|---| | **Auth** | | | `INVALID_CREDENTIALS` | Email ou senha errados no login | | `ACCOUNT_LOCKED` | Mais de 5 tentativas falhas em 15min | | `TOKEN_INVALID` | JWT malformado, expirado ou não encontrado | | `PASSWORD_CHANGED` | JWT foi emitido antes da senha ser alterada (ex: reset via link). O refresh-token também já foi revogado — cliente deve descartar o token e ir direto pro `/login` (ver `PASSWORD_RESET_TTL`) | | `REFRESH_FAILED` | Refresh token ausente ou já consumido | | `RESET_TOKEN_INVALID` | Token de reset não encontrado ou já usado (link expirou ou já consumido) | | `RESET_TOKEN_EXPIRED` | Token de reset passou da janela de 30min (`PASSWORD_RESET_TTL`) | | `EMAIL_ALREADY_REGISTERED` | Email já em uso no register | | `REGISTRATION_FAILED` | Falha interna durante o cadastro | | `CSRF_INVALID` | Token CSRF ausente ou não bate em request unsafe | | `OAUTH_PROVIDER_INVALID` | Provider social desconhecido | | `OAUTH_PROVIDER_DISABLED` | Provider social desabilitado ou sem credenciais configuradas | | `OAUTH_STATE_INVALID` | State OAuth ausente, expirado ou inválido | | `OAUTH_EMAIL_UNVERIFIED` | Provider não confirmou email verificado | | `OAUTH_PENDING_INVALID` | Token de conclusao de cadastro social inválido ou expirado | | `OAUTH_UPSTREAM_FAILED` | Falha ao conversar com Google/GitHub | | **Tenancy** | | | `COMPANY_SUSPENDED` | Empresa está com `status != active` | | `PLAN_LIMIT_REACHED` | Limite do plano atingido | | **Instância** | | | `INSTANCE_NOT_FOUND` | Instância não existe ou não pertence a empresa | | `INSTANCE_NOT_CONNECTED` | Tentativa de send em instância que não está CONNECTED | | `INSTANCE_BANNED` | Instância banida pelo WhatsApp | | `INSTANCE_HARD_PAUSED` | Throttler em hard pause após rate limits repetidos | | `DUPLICATE_INSTANCE_NAME` | Nome de instância já em uso na empresa | | `INVALID_INSTANCE_TRANSITION` | Transicao inválida (ex: connect em CREATED) | | **Mensageria** | | | `INVALID_JSON` | Corpo não e JSON válido | | `MISSING_FIELD` | Campo obrigatório ausente | | `INVALID_RECIPIENT` | Formato de destinatário inválido | | `INVALID_PHONE` | Número brasileiro com formato errado | | `IDEMPOTENCY_KEY_REQUIRED` | Header `Idempotency-Key` ausente em endpoint async | | `MEDIA_TOO_LARGE` | Arquivo excede limite de upload | | `UNSUPPORTED_MEDIA_TYPE` | MIME type não aceito | | `MEDIA_FETCH_FAILED` | API tentou baixar `media_url` no momento do request e falhou (DNS, 4xx/5xx do origem, SSRF block, timeout, oversized). HTTP 502. NÃO houve retry — request inteiro falha sincronamente, nada vai pra fila. Retry com URL válida ou faca upload prévio em `/media`. | | `MESSAGE_NOT_FOUND` | Mensagem não existe na tenant DB | | `INVALID_PIX_KEY` | Chave PIX inválida para o tipo declarado (CPF/CNPJ/PHONE/EMAIL/EVP). HTTP 400 | | `INTERACTIVE_DISABLED` | Endpoints de mensagem interativa (button/PIX/OTP/option-list) estão desativados — destinatários não renderizam via multi-device. HTTP 403. Ver API-MANUAL seção de mensagens + comparativo §15.9 | | `SEND_FAILED` | Falha ao criar/enfileirar a task | | `BLOCKED_DUPLICATE_CONTENT` | Anti-spam guard bloqueou conteúdo identico já enviado em 24h. Bypass: header `X-Force-Send: true`. | | `BLOCKED_NO_RECIPROCITY` | Anti-spam guard bloqueou envio consecutivo sem inbound do contato. Bypass: header `X-Force-Send: true`. | | `BLOCKED_TEMPERATURE_LIMIT` | Regra de temperatura bloqueou outbound — `max_consecutive` excedido para o tier do score do contato (`cold=2`, `warm=3`, `engaged=4`, `hot=5`, `very_hot=7`). Payload inclui `score`, `tier`, `max_consecutive`, `outbound_consecutive`. Bypass: header `X-Force-Send: true` (audit-logged). | | **Webhook** | | | `WEBHOOK_NOT_FOUND` | Webhook não existe | | `WEBHOOK_URL_INVALID` | URL inválida (HTTP em prod, scheme não suportado, SSRF) | | `WEBHOOK_URL_BLOCKED` | URL em dominio bloqueado | ### Casando o erro do frontend com o log do backend Quando o frontend recebe um erro, ele também recebe o `trace_id`. Para encontrar a linha exata no log: ```bash TRACE_ID="2a62142c-f55f-4db4-8c7b-060018e3ef48" TOKEN=$(curl -sf https://api.catcher.one/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"admin@biazap.test","password":""}' | jq -r .token) curl -sf "https://api.catcher.one/v1/admin/logs?trace_id=$TRACE_ID&limit=20" \ -H "Authorization: Bearer $TOKEN" | jq '.lines[]' -r ``` O endpoint `/v1/admin/logs` aceita os filtros: `file`, `limit`, `skip`, `level`, `contains`, `instance_id`, `trace_id`, `error_code`. Use `trace_id` para uma única request, `error_code` para encontrar todas as ocorrências de um erro especifico. A linha de log do request completed inclui os campos `trace_id`, `error_code`, `status`, `duration_ms`, `path`, `method`, `remote_addr` — tudo o que você precisa para diagnosticar o problema. --- ## 20. Números Brasileiros A API normaliza automaticamente números brasileiros (BR) com o nono digito extra. **Problema:** O WhatsApp registra números BR móveis com 12 digitos (`55XX9XXXXXXX`), mas muitas vezes o número e fornecido com 13 digitos (`55XX9XXXXXXXX`). **Solução automática:** A função `normalizeBR()` detecta e corrige: - Entrada: `5541996332719` (13 digitos) - Saída: `554196332719` (12 digitos - remove o 9 extra) Isso e aplicado em todos os pontos que recebem números do usuário: - Campo `to` em mensagens - `contact_id` em block/unblock - `participants` em grupos - `numbers` em check de contatos > Você não precisa se preocupar com o formato do número. Envie como você tem e a API cuida do resto. --- ## 21. Conflito de Número Duplicado A API impede que o mesmo número de telefone esteja conectado em mais de uma instância simultaneamente. ### O que acontece 1. Usuário cria uma nova instância e escaneia o QR code com um número que **já está conectado** em outra instância 2. O WhatsApp conecta momentaneamente 3. A API detecta o conflito e **automaticamente faz logout** da instância nova (a duplicada) 4. A instância original contínua funcionando normalmente ### Como detectar o conflito **Via REST (polling):** Consulte `GET /v1/instances/{id}` e verifique o campo `last_error`: ```json { "id": "aaa-bbb-...", "name": "Duplicada", "status": "DISCONNECTED", "last_error": "phone 554192464230 already connected on instance 84c2e480-...", "updated_at": "2026-03-08T01:00:05Z" } ``` **Via SSE/WebSocket (tempo real):** Ouça o evento `connection.update` com status `conflict`: ```json { "event_type": "connection.update", "instance_id": "aaa-bbb-...", "data": { "status": "conflict", "reason": "phone 554192464230 already connected on instance 84c2e480-..." } } ``` **Via Webhook:** O mesmo evento `connection.update` e entregue no seu endpoint. ### Comportamento | Situação | Resultado | |----------|-----------| | Número novo (não conectado em nenhuma instância) | Conecta normalmente | | Número já conectado em outra instância | Logout automático da nova instância + `last_error` preenchido | | Reconexao da mesma instância com o mesmo número | Conecta normalmente | | `last_error` após conflito | Limpo automaticamente na próxima conexão bem-sucedida | --- ## 22. Identificadores e Correlação de Eventos Está seção explica como JIDs, LIDs e números de telefone fluem pelo sistema, e como correlacionar eventos a conversas e mensagens. ### Glossario de Identificadores | Termo | Formato | Exemplo | O que e | |-------|---------|---------|---------| | **Phone JID** | `@s.whatsapp.net` | `554196332719@s.whatsapp.net` | Identificador de contato individual | | **Group JID** | `@g.us` | `120363012345678901@g.us` | Identificador de grupo | | **LID** | `:@lid` | `216251804708983:34@lid` | Linked ID — formato interno do WhatsApp para dispositivos vinculados | | **Base LID** | `@lid` | `216251804708983@lid` | LID sem sufixo de dispositivo, usado como chat JID | | **chat_lid** | `@lid` | `42812401807390@lid` | Campo auxiliar nos eventos — LID original do chat antes da resolução para phone JID | | **sender_lid** | `:@lid` | `42812401807390:77@lid` | Campo auxiliar nos eventos — LID original do sender antes da resolução para phone JID | | **RemoteJID** | Phone JID ou Group JID | — | Campo no banco que identifica a conversa | | **WhatsAppID** | string alfanumerica | `3EB0A1B2C3D4E5F6` | ID único da mensagem atribuido pelo WhatsApp | | **ExternalID** | UUID v4 | `a1b2c3d4-e5f6-...` | ID da mensagem na API BiaZap | | **InstanceID** | UUID v4 | `84c2e480-...` | Qual instância WhatsApp (número conectado) | | **SenderJID** | Phone JID | `554196332719@s.whatsapp.net` | Quem enviou a mensagem (util em grupos) | ### Normalizacao automática A API normaliza automaticamente todos os identificadores: 1. **LID → Phone JID:** O WhatsApp pode enviar identificadores no formato LID (`@lid`). A API tenta resolver LIDs para Phone JID (`@s.whatsapp.net`) usando: campo `SenderAlt` do protocolo, registro de identidade de contatos (tabelas `contacts`/`contact_identities`), e busca na tabela `messages` como último fallback. **Os campos principais (`phone`, `chat`, `from`, `to`, `sender`) sempre contem o phone JID quando a resolução e possível.** Quando o LID original estava presente, ele e exposto nos campos auxiliares `chat_lid` e `sender_lid` para correlação. 2. **BR 13→12 digitos:** Números brasileiros com 13 digitos (`55XX9XXXXXXXX`) são normalizados para 12 digitos (`55XXXXXXXXXX`), removendo o 9 extra. Isso e aplicado em todas as direcoes (inbound e outbound). 3. **Formato consistente:** O campo `chat` em todos os eventos e identico ao campo `remote_jid` no banco. Você pode agrupar eventos em conversas usando `instance_id + chat`. ### Campos LID auxiliares (`chat_lid`, `sender_lid`) Quando o WhatsApp usa internamente um LID para identificar um contato ou chat, a API expoe o LID original nos campos opcionais `chat_lid` e `sender_lid` (omitidos quando vazios). Esses campos são uteis para: - **Correlação de eventos:** Quando `phone` está vazio (primeiro contato via LID, sem mapeamento no registro), use `chat_lid` para agrupar `message.sent` + `message.delivered` + `message.read` + `message.played` da mesma conversa. - **Correlação retroativa:** Quando um `message.received` chega com `phone` preenchido e `chat_lid` presente, você pode retroativamente associar eventos anteriores que tinham `phone` vazio mas o mesmo `chat_lid`. - **Debug:** Os campos auxiliares permitem diagnosticar quando o WhatsApp opera em modo LID e verificar se a resolução de identidade está funcionando. **Disponibilidade dos campos LID por evento:** | Evento | `chat_lid` | `sender_lid` | |--------|:----------:|:------------:| | `message.received` | Sim | Sim | | `message.sent` | Sim | — | | `message.delivered` | Sim | Sim | | `message.read` | Sim | Sim | | `message.played` | Sim | Sim | | `message.deleted` | Sim | Sim | | `message.edited` | Sim | Sim | > **Aprendizado automático:** A API aprende mapeamentos LID→telefone conforme interage com contatos. Quando um contato responde (`message.received` com `SenderAlt`), o mapeamento e salvo no registro de identidade. A partir dai, todos os eventos futuros para esse LID terao `phone` preenchido automaticamente. ### Modelo de dados da mensagem A tabela `messages` no banco de cada tenant armazena todas as mensagens (inbound e outbound): | Campo | Tipo | Descricao | |-------|------|-----------| | `id` (UUID) | UUID | ID público na API (exposto como elemento de `message_ids` nos eventos) | | `instance_id` | string | Qual instância WhatsApp | | `direction` | string | `"inbound"` ou `"outbound"` | | `remote_jid` | string | **A conversa.** Phone JID (1:1) ou Group JID (grupo) | | `sender_jid` | string | **Quem enviou.** Phone JID do remetente. Util para mensagens de grupo, onde `remote_jid` e o JID do grupo | | `message_type` | string | text, image, video, audio, document, sticker, location, contact, reaction, poll | | `content` | string | Texto ou caption | | `status` | string | queued → sent → delivered → read / failed / deleted | | `whatsapp_id` | string | ID da mensagem no WhatsApp (chave de correlação para status updates) | | `source` | string | `"api"` (fila BiaZap), `"external"` (WhatsApp Web/celular) | | `media_id` | string | Referência a mídia no S3 | **Como `remote_jid` funciona por direcao:** | Cenario | Inbound `remote_jid` | Outbound `remote_jid` | Mesmo valor? | |---------|---------------------|----------------------|:------------:| | Chat 1:1 com 5541... | `554196332719@s.whatsapp.net` | `554196332719@s.whatsapp.net` | Sim | | Grupo 120363... | `120363012345678901@g.us` | `120363012345678901@g.us` | Sim | > **Não existe tabela `Chat`.** Conversas são derivadas agrupando mensagens por `remote_jid`. O endpoint `GET /v1/instances/{instanceId}/chats` faz isso automaticamente. ### Como `sender_jid` funciona | Cenario | `sender_jid` | `remote_jid` | |---------|-------------|--------------| | Inbound 1:1 | `554196332719@s.whatsapp.net` (quem mandou) | `554196332719@s.whatsapp.net` (a conversa) | | Inbound grupo | `554196332719@s.whatsapp.net` (quem mandou) | `120363012345678901@g.us` (o grupo) | | Outbound (API) | vazio (a instância e o remetente) | JID do destinatário | | Outbound (externo) | vazio (a instância e o remetente) | JID do destinatário | > Para mensagens de grupo, `sender_jid` e essencial para saber **quem** disse cada coisa dentro do grupo. ### Correlação: como agrupar eventos em conversas **Chave primaria da conversa:** `instance_id` + campo `chat` do evento (ou `remote_jid` no banco) **Chave secundaria (quando `chat` e um LID não resolvido):** `instance_id` + campo `chat_lid` do evento Todos os eventos de mensagem incluem o campo `chat`, que corresponde exatamente ao `remote_jid` no banco: | Evento | Campo de conversa | Campo auxiliar LID | Campo de mensagem (UUID BiaZap) | |--------|------------------|-------------------|---------------------------------| | `message.received` | `chat` | `chat_lid` | `message_ids` (array) | | `message.sent` | `chat` | `chat_lid` | `message_ids` (array) | | `message.delivered` | `chat` | `chat_lid` | `message_ids` (array) | | `message.read` | `chat` | `chat_lid` | `message_ids` (array) | | `message.played` | `chat` | `chat_lid` | `message_ids` (array) | | `message.deleted` | `chat` | `chat_lid` | `message_ids` (array) | | `message.edited` | `chat` | `chat_lid` | `message_ids` (array) | > **Padronizacao:** **todos** os eventos de mensagem usam `message_ids` (array). Para a maioria, o array tem exatamente 1 elemento; apenas `message.delivered`, `message.read` e `message.played` podem trazer multiplos quando o WhatsApp agrega confirmacoes em batch. Consumers leem `event.data.message_ids[0]` na maioria dos casos e iteram o array nos eventos de status. **Cenario LID (APIs externas):** Quando outra API envia mensagens via LID e a BiaZap ainda não conhece o telefone associado, os eventos `message.sent` e `message.delivered` terao `phone` vazio e `chat` com o LID. Use `chat_lid` como chave de agrupamento. Quando o contato responder, a BiaZap aprendera o mapeamento, e a partir dai os eventos passarao a ter `phone` e `chat` com o phone JID. Você pode correlacionar retroativamente comparando o `chat_lid` dos novos eventos com os anteriores. ### Correlação: como rastrear o lifecycle de uma mensagem O ciclo de vida de uma mensagem outbound e rastreado pelo `message_ids[0]` (UUID do BiaZap): ``` message.sent (message_ids = ["550e8400-e29b-41d4-a716-..."]) ↓ message.delivered (message_ids = ["550e8400-e29b-41d4-a716-..."]) ↓ message.read (message_ids = ["550e8400-e29b-41d4-a716-..."]) ↓ (se view-once) message.played (message_ids = ["550e8400-e29b-41d4-a716-..."]) ``` No banco, os timestamps são atualizados conforme os eventos chegam: ``` queued_at → sent_at → delivered_at → read_at → played_at (view-once) ↓ deleted_at (se revogada) ``` #### message_ids — UUIDs do BiaZap O campo `message_ids` em todos os eventos de mensagem contem um **array de UUIDs internos do BiaZap** (v4, 36 chars cada). Este identificador e: - **Garantido único** — nunca reciclado, mesmo quando o WhatsApp reutiliza IDs após delecao - **Estável** — o mesmo UUID persiste em todos os eventos do ciclo de vida da mensagem (received → delivered → read → played → deleted) - **Aceito nos endpoints da API** — os endpoints de delete, edit, react, forward e mark-read aceitam tanto o UUID do BiaZap quanto o ID do WhatsApp (hex) Para a maioria dos eventos (`message.received`, `message.sent`, `message.deleted`, `message.edited`, `message.starred`, `message.deleted_for_me`, `media.retry_result`, `label.message_association`), o array tem **exatamente 1 elemento** — leia `event.data.message_ids[0]`. Apenas `message.delivered`, `message.read` e `message.played` podem trazer multiplos elementos quando o WhatsApp agrega receipts. O campo `whatsapp_message_ids` aparece nos eventos de mensagem como trilha forense: ele carrega o(s) ID(s) bruto(s) do WhatsApp usados nos traces whatsmeow, receipts e envelopes de edit/delete. Para operações da API e integrações de negócio, continue usando `message_ids`. > **Nota:** `message.undecryptable`, `message.decrypt_recovered` e `message.decrypt_lost` carregam o ID do WhatsApp também em `message_ids[0]`, por compatibilidade histórica e porque não há UUID Catcher quando a mensagem não foi decriptada. --- ### Diferenciacao: 1:1 vs grupo O campo `is_group` está disponível nos seguintes eventos: | Evento | `is_group` | Como identificar se ausente | |--------|:----------:|---------------------------| | `message.received` | Sim | — | | `message.sent` | Sim | — | | `message.delivered` | Sim | — | | `message.read` | Sim | — | | `message.played` | Sim | — | | `message.deleted` | Não | `chat` termina com `@g.us` | | `message.edited` | Sim | — | > Quando `is_group` não estiver presente, você pode inferir pelo sufixo do campo `chat`: `@g.us` = grupo, `@s.whatsapp.net` = individual. ### Endpoints de busca por identificador | Preciso de... | Endpoint | Parametro | |---------------|----------|-----------| | Listar conversas | `GET /v1/instances/{id}/chats` | — | | Mensagens de uma conversa | `GET /v1/instances/{id}/chats/{chatJID}/messages` | `chatJID` = `remote_jid` = `chat` dos eventos | | Uma mensagem especifica | `GET /v1/instances/{id}/message/{messageId}` | `messageId` = UUID do BiaZap (elemento de `message_ids` do evento) ou ID do WhatsApp (hex) | | Mídias de uma conversa | `GET /v1/instances/{id}/media?remote_jid={chatJID}` | `remote_jid` = `chat` dos eventos | --- ## 23. Billing Endpoints da plataforma SaaS de billing — assinaturas, planos, add-ons, cartoes, cobrancas e webhooks de provedor de pagamento (Asaas). > **Base URL:** `https://api.pay.catcher.one` > **Serviço:** `biazap-payment` (porta 8093 interna) > **Persistencia:** todas as tabelas billing vivem em `biazap_master` (NÃO no tenant DB) > **Provider:** Asaas atrás da interface `provider.PaymentProvider` (ACL — substituivel) ### 23.1 Catalogo (público, sem auth) #### GET /v1/billing/plans Lista os planos publicados ordenados por `sort_order ASC`. **Auth:** não requer. **Query params:** | Param | Tipo | Padrão | Descricao | |-------|------|--------|-----------| | `cycle` | string | — | Filtra por ciclo (`monthly` ou `yearly`). Omita para todos. | **Resposta 200:** ```json { "data": [ { "code": "starter_monthly", "product_code": "starter", "display_name": "Starter", "display_subtitle": "Pra comecar", "cycle": "monthly", "currency": "BRL", "price_cents": 9900, "trial_days": 30, "sort_order": 10 } ] } ``` #### GET /v1/billing/addons Lista os add-ons publicados. **Auth:** não requer. **Resposta 200:** ```json { "data": [ { "code": "extra_instance", "display_name": "Instancia adicional", "description": "Uma sessao WhatsApp extra", "cycle": "monthly", "currency": "BRL", "price_cents": 2900, "unit_label": "instancia", "max_quantity": 50 } ] } ``` --- ### 23.2 Profile (perfil de cobranca da empresa) #### GET /v1/billing/profile Retorna o `BillingProfile` da empresa autenticada. **Auth:** JWT. **Resposta 200:** `ProfileDTO` (ver 23.8). **Erros:** `404 PROFILE_NOT_FOUND` quando ainda não foi criado. #### POST /v1/billing/profile Cria o profile. Cria também o customer no Asaas e armazena `asaas_customer_id`. **Auth:** JWT. **Request body:** `ProfileDTO` SEM os campos `id` / `asaas_customer_id` (o servidor preenche). **Resposta 200/201:** `ProfileDTO` completo. **Erros:** - `400 INVALID_DOCUMENT` — CPF/CNPJ malformado. - `409 PROFILE_EXISTS` — empresa já tem profile (use `PATCH`). #### PATCH /v1/billing/profile Atualiza campos do profile (nome, endereço, telefone, etc.). Mudancas relevantes propagam para o Asaas customer. **Auth:** JWT. **Request body:** `ProfileDTO` parcial (apenas os campos a alterar). **Resposta 200:** `ProfileDTO` atualizado. --- ### 23.3 Subscriptions #### GET /v1/billing/subscriptions/current Retorna a assinatura ativa (1 viavel por empresa) com seus `items[]`. **Auth:** JWT. **Resposta 200:** `SubscriptionDTO` (com `items[]` populado). **Erros:** `404 SUBSCRIPTION_NOT_FOUND` quando a empresa nunca assinou. #### POST /v1/billing/subscriptions Cria uma nova assinatura. Inicia em `trialing` (30 dias) e provisiona a primeira cobranca no Asaas conforme `billing_type`. **Auth:** JWT. Requer `BillingProfile` previamente criado. **Request body:** ```json { "plan_code": "starter_monthly", "billing_type": "pix", "card_id": null } ``` | Campo | Tipo | Obrigatório | Descricao | |-------|------|-------------|-----------| | `plan_code` | string | sim | Código de plano publicado em `/v1/billing/plans`. | | `billing_type` | string | sim | `pix`, `boleto` ou `credit_card`. | | `card_id` | string | so quando `billing_type=credit_card` | ID de cartao previamente tokenizado. | **Resposta 201:** `SubscriptionDTO`. **Erros:** - `400 PLAN_NOT_FOUND` — `plan_code` inválido. - `400 PROFILE_REQUIRED` — empresa sem `BillingProfile`. - `400 INVALID_BILLING_TYPE` — `billing_type` fora de `pix|boleto|credit_card`. - `400 CARD_REQUIRED` — `billing_type=credit_card` sem `card_id`. - `400 CARD_REJECTED` — Asaas rejeitou a tokenizacao no checkout inicial. - `409 SUBSCRIPTION_EXISTS` — empresa já tem assinatura ativa. #### POST /v1/billing/subscriptions/{id}/change-plan Agenda mudanca de plano para o próximo ciclo. Set `next_plan_id`; o swap real acontece quando o webhook PAYMENT_RECEIVED do próximo periodo chega. **Auth:** JWT. **Request body:** ```json { "new_plan_code": "pro_monthly" } ``` **Resposta 202:** `SubscriptionDTO` com `next_plan_id` populado. **Erros:** - `400 PLAN_NOT_FOUND` — `new_plan_code` inválido. - `400 ALREADY_ON_PLAN` — `new_plan_code` igual ao atual. - `400 SUBSCRIPTION_NOT_ACTIVE` — assinatura não está em `trialing|active|past_due`. #### POST /v1/billing/subscriptions/{id}/cancel Cancela a assinatura. **Auth:** JWT. **Request body:** ```json { "immediate": false, "reason": "Cliente migrou para plano externo" } ``` | Campo | Tipo | Padrão | Descricao | |-------|------|--------|-----------| | `immediate` | bool | `false` | `true` cancela na hora; `false` agenda `cancel_at_period_end=true` para encerrar quando o periodo atual fechar. | | `reason` | string | — | Texto livre para auditoria interna. | **Resposta 204:** sem corpo. #### POST /v1/billing/subscriptions/{id}/items Adiciona add-on a assinatura (vira `BillingSubscriptionItem`). Cobranca adicional entra na próxima fatura. **Auth:** JWT. **Request body:** ```json { "addon_code": "extra_instance", "quantity": 2 } ``` **Resposta 202:** `SubscriptionItemDTO` recem-criado com `scheduled_for_next_cycle=true`. **Erros:** - `404 ADDON_NOT_FOUND` — `addon_code` inválido. - `400 INVALID_QUANTITY` — `quantity` <= 0 ou acima do `max_quantity` do add-on. #### DELETE /v1/billing/subscriptions/{id}/items/{itemId} Remove um item da assinatura. O crédito proporcional, quando aplicavel, segue a politica do Asaas. **Auth:** JWT. **Resposta 204:** sem corpo. **Erros:** `404 ITEM_NOT_FOUND`. --- ### 23.4 Cards (cartoes tokenizados) > **PCI SAQ-D.** Tokenizacao acontece server-side. PAN e CVV NUNCA são persistidos: defesa em 3 camadas — Sentinel `WithUserContentPaths` no endpoint `tokenize`, incident recorder com paths excluidos da captura, e zerar PAN/CVV no service layer após chamada Asaas. #### GET /v1/billing/cards Lista cartoes ativos da empresa. **Auth:** JWT. **Resposta 200:** ```json { "data": [ /* CardDTO[] */ ] } ``` #### POST /v1/billing/cards/tokenize Tokeniza um cartao: chama Asaas, recebe token, persiste apenas `brand`, `last4`, `exp_month`, `exp_year`, `holder_name` e `is_default`. PAN/CVV são **descartados** após a chamada. **Auth:** JWT. **Request body:** ```json { "billing_profile_id": "uuid", "number": "4111111111111111", "holder_name": "Joao Silva", "exp_month": "12", "exp_year": "2030", "cvv": "123" } ``` **Resposta 201:** `CardDTO` (sem PAN, sem CVV). **Erros:** `400 CARD_REJECTED` quando o Asaas rejeita. #### PATCH /v1/billing/cards/{id}/default Marca este cartao como o default; rebaixa os demais. **Auth:** JWT. **Resposta 200:** `CardDTO` atualizado. #### DELETE /v1/billing/cards/{id} Revoga o cartao localmente (e no Asaas, quando suportado). **Auth:** JWT. **Resposta 204:** sem corpo. --- ### 23.5 Charges (cobrancas) #### GET /v1/billing/charges Lista cobrancas da empresa, ordenadas por `due_date DESC`. **Auth:** JWT. **Query params:** | Param | Tipo | Padrão | Descricao | |-------|------|--------|-----------| | `status` | string | — | Filtra por status (`pending`, `confirmed`, `received`, `overdue`, `refunded`, `chargeback`, etc.). | | `limit` | int | 20 | Máximo 100. | **Resposta 200:** ```json { "data": [ /* ChargeDTO[] */ ] } ``` --- ### 23.6 Webhooks (entrada do Asaas) #### POST /v1/webhooks/asaas Endpoint de ingestao de webhooks do Asaas. Idempotente via `UNIQUE` em `asaas_event_id`. **Auth:** header `asaas-access-token` validado por `crypto/subtle.ConstantTimeCompare` contra `ASAAS_WEBHOOK_SECRET`. **Request body:** payload do Asaas (variavel por `event`). **Resposta:** - `200` — evento aceito (novo OU duplicado — idempotencia retorna 200 sem reprocessar). - `401` — assinatura inválida. **Eventos suportados (mapeamento → estado):** | Asaas event | Ação em `BillingSubscription` | |-------------|-------------------------------| | `PAYMENT_CREATED` | Cria/atualiza `BillingCharge` (status `pending`). | | `PAYMENT_CONFIRMED` | Marca charge `confirmed` (PIX confirmado, boleto compensado). | | `PAYMENT_RECEIVED` | Marca charge `received`; promove subscription `trialing → active`; aplica `next_plan_id` (mudanca de plano agendada). | | `PAYMENT_OVERDUE` | Marca charge `overdue`; promove subscription `active → past_due`. | | `PAYMENT_FAILED` (`REPROVED_BY_RISK_ANALYSIS`) | Marca charge `failed`. | | `PAYMENT_REFUNDED` | Marca charge `refunded`. | | `PAYMENT_CHARGEBACK_REQUESTED` | Marca charge `chargeback`; promove subscription para `suspended` imediatamente; pública em `biazap:billing:suspended`. | | `PAYMENT_DELETED` | Marca charge `deleted`. | | `SUBSCRIPTION_DELETED` | Promove subscription para `cancelled`. | **Retry:** processador Asynq com backoff capeado (`1m / 5m / 15m / 1h / 6h`). Falhas terminais marcam o evento como `failed` em `billing_webhook_events`. --- ### 23.7 Admin (superadmin) #### GET /v1/admin/billing/metrics Metricas agregadas da plataforma. **Auth:** JWT + superadmin. **Resposta 200:** `AdminBillingMetricsDTO` (ver 23.8). #### GET /v1/admin/billing/subscriptions Últimas 200 subscriptions ordenadas por `created_at DESC`. **Auth:** JWT + superadmin. **Resposta 200:** ```json { "data": [ /* SubscriptionDTO[] */ ] } ``` #### POST /v1/admin/billing/webhook-events/{id}/reprocess Re-enfileira um webhook event que ficou `failed` após esgotar retries (ex: bug do consumer já corrigido). **Auth:** JWT + superadmin. **Resposta 202:** sem corpo. --- ### 23.8 DTOs #### PlanDTO ```json { "code": "starter_monthly", "product_code": "starter", "display_name": "Starter", "display_subtitle": "Pra comecar", "cycle": "monthly", "currency": "BRL", "price_cents": 9900, "trial_days": 30, "sort_order": 10 } ``` #### AddonDTO ```json { "code": "extra_instance", "display_name": "Instancia adicional", "description": "Uma sessao WhatsApp extra", "cycle": "monthly", "currency": "BRL", "price_cents": 2900, "unit_label": "instancia", "max_quantity": 50 } ``` #### ProfileDTO ```json { "id": "uuid", "document_type": "cnpj", "document_number": "12345678000190", "legal_name": "Acme Ltda", "trade_name": "Acme", "email": "billing@acme.com", "phone": "554137984905", "address": { "zip": "80000000", "street": "Rua das Acacias", "number": "123", "complement": "Sala 4", "district": "Centro", "city": "Curitiba", "state": "PR", "country": "BR" }, "asaas_customer_id": "cus_abc123" } ``` | Campo | Tipo | Obs | |-------|------|-----| | `document_type` | string | `cpf` ou `cnpj`. | | `document_number` | string | digitos apenas. | | `address` | object | endereço completo do faturamento. | | `asaas_customer_id` | string | preenchido pelo servidor; não envie no `POST`. | #### SubscriptionDTO ```json { "id": "uuid", "company_id": 9, "plan_code": "starter_monthly", "status": "active", "billing_type": "pix", "trial_ends_at": "2026-05-10T00:00:00Z", "current_period_end": "2026-06-10T00:00:00Z", "asaas_subscription_id": "sub_xyz", "cancel_at_period_end": false, "items": [ /* SubscriptionItemDTO[] */ ] } ``` | Campo `status` | Significado | |---------------|-------------| | `trialing` | dentro dos 30 dias de teste. | | `active` | pago e em vigor. | | `past_due` | cobranca em atraso (`PAYMENT_OVERDUE`); em janela de graca de 3 dias antes da suspensao. | | `suspended` | suspenso (gracioso ou chargeback); whats-worker desconectou as instâncias via `LogoutInstancesByCompany`. | | `cancelled` | encerrado definitivamente. | | `expired` | trial venceu sem conversao. | #### SubscriptionItemDTO ```json { "id": "uuid", "type": "addon", "code": "extra_instance", "display_name": "Instancia adicional", "quantity": 2, "unit_price_cents": 2900, "scheduled_for_next_cycle": true } ``` | Campo `type` | Significado | |--------------|-------------| | `plan` | linha do plano base (sempre 1 por subscription, `quantity=1`). | | `addon` | linha de add-on (`quantity` >= 1, até `max_quantity` do add-on). | #### CardDTO ```json { "id": "uuid", "brand": "visa", "last4": "1111", "exp_month": "12", "exp_year": "2030", "holder_name": "Joao Silva", "is_default": true } ``` > Nunca exposto: PAN, CVV, token bruto do Asaas. #### ChargeDTO ```json { "id": "uuid", "asaas_payment_id": "pay_abc", "billing_type": "pix", "status": "received", "amount_cents": 9900, "net_value_cents": 9655, "fee_cents": 245, "due_date": "2026-05-10", "paid_at": "2026-05-09T17:42:11Z", "invoice_url": "https://asaas.com/invoice/abc", "bank_slip_url": null, "pix_copy_paste": "00020126...", "pix_qr_code": "data:image/png;base64,iVBOR..." } ``` | Campo | Quando preenchido | |-------|-------------------| | `bank_slip_url` | so quando `billing_type=boleto`. | | `pix_copy_paste` / `pix_qr_code` | so quando `billing_type=pix` e charge `pending|confirmed`. | | `paid_at` | so quando `status=received|confirmed`. | #### AdminBillingMetricsDTO ```json { "total_active_subs": 42, "total_trialing": 7, "total_past_due": 2, "total_suspended": 1, "monthly_recurring_cents": 415800 } ``` --- ### 23.9 Códigos de erro especificos do billing Adicionados a lista canonica em `whats-payment/internal/api/error_codes.go` (mesmo envelope da seção 19): | `error_code` | HTTP | Quando ocorre | |--------------|------|---------------| | `PROFILE_NOT_FOUND` | 404 | `GET /v1/billing/profile` antes de criar. | | `PROFILE_EXISTS` | 409 | `POST /v1/billing/profile` com profile já existente. | | `PROFILE_REQUIRED` | 400 | Subscription create sem profile previo. | | `INVALID_DOCUMENT` | 400 | CPF/CNPJ falha na validação. | | `PLAN_NOT_FOUND` | 400 | `plan_code` ou `new_plan_code` inválido. | | `SUBSCRIPTION_NOT_FOUND` | 404 | `GET /v1/billing/subscriptions/current` sem assinatura. | | `SUBSCRIPTION_EXISTS` | 409 | `POST /v1/billing/subscriptions` com assinatura ativa. | | `SUBSCRIPTION_NOT_ACTIVE` | 400 | `change-plan` em assinatura `cancelled|expired|suspended`. | | `ALREADY_ON_PLAN` | 400 | `change-plan` para o mesmo `plan_code` corrente. | | `CARD_REQUIRED` | 400 | `billing_type=credit_card` sem `card_id`. | | `CARD_REJECTED` | 400 | Asaas rejeitou tokenizacao ou checkout. | | `INVALID_BILLING_TYPE` | 400 | `billing_type` fora de `pix|boleto|credit_card`. | | `INVALID_QUANTITY` | 400 | Item add-on com `quantity <= 0` ou > `max_quantity`. | | `ITEM_NOT_FOUND` | 404 | `DELETE` em item inexistente. | | `ADDON_NOT_FOUND` | 404 | `addon_code` inválido. | | `INVALID_JSON` | 400 | Corpo não e JSON válido. | | `INVALID_ID` | 400 | UUID malformado em path param. | | `UNAUTHENTICATED` | 401 | Token ausente/inválido em endpoint protegido. | | `INTERNAL` | 500 | Erro interno (ver `incident_id` no envelope). | --- ### 23.10 Fluxo completo (curl) ```bash # 1. Login para obter token TOKEN=$(curl -sf https://api.catcher.one/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"owner@empresa.com","password":"..."}' | jq -r .token) # 2. Criar profile de cobranca (CNPJ) curl -sf -X POST https://api.pay.catcher.one/v1/billing/profile \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "document_type": "cnpj", "document_number": "12345678000190", "legal_name": "Acme Ltda", "email": "billing@acme.com", "phone": "554137984905", "address": { "zip": "80000000", "street": "Rua das Acacias", "number": "123", "district": "Centro", "city": "Curitiba", "state": "PR", "country": "BR" } }' | jq # 3. Listar planos disponiveis curl -sf "https://api.pay.catcher.one/v1/billing/plans?cycle=monthly" | jq # 4. Assinar (PIX, sem cartao) curl -sf -X POST https://api.pay.catcher.one/v1/billing/subscriptions \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"plan_code":"starter_monthly","billing_type":"pix"}' | jq # 5. Listar cobrancas para pegar o PIX copy-paste curl -sf "https://api.pay.catcher.one/v1/billing/charges?limit=5" \ -H "Authorization: Bearer $TOKEN" | jq '.data[0] | {status,pix_copy_paste,due_date}' ``` --- ## Guia Rápido - Fluxo Completo ### 1. Registrar empresa e obter token ```bash # Registrar curl -X POST https://api.catcher.one/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "company_name": "Minha Empresa", "owner_email": "eu@empresa.com", "owner_name": "Joao", "password": "MinhaSenh@123" }' # Salve o token retornado (bza_xxxx...) ``` ### 2. Criar e conectar instância ```bash TOKEN="bza_xxxx..." # Criar instancia curl -X POST https://api.catcher.one/v1/instances \ -H "X-API-Key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "Principal"}' # Salve o ID retornado INSTANCE="84c2e480-..." # Conectar (gera QR code) curl -X POST https://api.catcher.one/v1/instances/$INSTANCE/connect \ -H "X-API-Key: $TOKEN" # Escaneie o QR code no WhatsApp # Ou use pairing code curl -X POST https://api.catcher.one/v1/instances/$INSTANCE/pairing-code \ -H "X-API-Key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"phone": "554137984905"}' ``` ### 3. Enviar mensagem ```bash curl -X POST https://api.catcher.one/v1/instances/$INSTANCE/messages/text \ -H "X-API-Key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to": "554137984905", "text": "Ola! Teste do BiaZap" }' ``` ### 4. Configurar webhook para receber eventos ```bash curl -X POST https://api.catcher.one/v1/webhooks \ -H "X-API-Key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://meuservidor.com/webhook", "events": "message.received,message.sent" }' ``` ### 5. Ouvir eventos em tempo real ```bash # SSE curl -N \ -H "Authorization: Bearer $TOKEN" \ "https://api.catcher.one/v1/instances/$INSTANCE/events" # Ou via WebSocket com wscat wscat -H "Authorization: Bearer $TOKEN" -c "wss://api.catcher.one/v1/instances/$INSTANCE/ws" ``` ### 6. Buscar mensagem e baixar mídia recebida ```bash # Buscar mensagem pelo UUID do BiaZap (elemento de message_ids do evento) ou pelo ID do WhatsApp (hex) curl -H "X-API-Key: $TOKEN" \ "https://api.catcher.one/v1/instances/$INSTANCE/message/3EB0ABC123DEF456" # Resposta inclui media_id se a mensagem tiver midia # Baixar midia (imagem, audio, video, documento, sticker) MEDIA_ID="b2c3d4e5-f6a7-8901-bcde-f12345678901" curl -o arquivo_recebido.ogg \ -H "X-API-Key: $TOKEN" \ "https://api.catcher.one/v1/instances/$INSTANCE/media/$MEDIA_ID" ``` ## Per-Instance Usage & Delivery Logs (Detail View) ### GET /v1/instances/:instanceId/usage Returns per-instance message counters for the last 24 hours. Used by the detail view's "Mensagens" section. **Auth:** JWT or API key (any role). **Response 200:** ```json { "messages_today": 342, "messages_sent_today": 120, "messages_received_today": 222, "delivered_today": 115, "failed_today": 5 } ``` | Field | Meaning | |---|---| | `messages_today` | Sent + received in the last 24h. | | `messages_sent_today` | Outbound messages in the last 24h. | | `messages_received_today` | Inbound messages in the last 24h. | | `delivered_today` | Outbound messages that reached `status=delivered` in the last 24h. | | `failed_today` | Outbound messages that reached `status=failed` in the last 24h. | ### GET /v1/webhooks/delivery-logs Returns the latest delivery logs across all of the caller's webhooks, optionally filtered by `instance_id`. Used by the detail view's "Webhook deliveries" section so a caller can inspect deliveries for a specific instance without knowing which webhook fired. **Auth:** JWT or API key (any role). Scoped to the caller's company. **Query params:** | Param | Type | Default | Meaning | |---|---|---|---| | `instance_id` | string | — | Restrict to deliveries from webhooks scoped to this instance OR global webhooks. Omit to see all. | | `limit` | int | 20 | Page size (max 100). | | `page` | int | 1 | Page number (1-based). | ### GET /v1/webhooks/instance-events Returns the unified events feed for a single instance, aggregated by `event_id` (one row per event with the latest delivery attempt's state). Powers the **Eventos** panel on the instance detail Webhooks tab with filter chips for status. Use this to audit ANY event sent to/from an instance — successful AND failed — without leaving the instance page. Distinct from `/v1/webhooks/stuck`, which only returns hard-failed events for the cross-instance dashboard widget. **Auth:** JWT or API key (any role). Scoped to the caller's company. **Aggregation rule:** rows are grouped by `event_id`, and the LATEST delivery attempt (max log id) determines the row's `success`, `status_code`, `error_class`, etc. The `attempts` field is the highest attempt number observed for the event. **Query params:** | Param | Type | Default | Meaning | |---|---|---|---| | `instance_id` | string | **required** | The instance to scope to. Includes deliveries from webhooks scoped to this instance AND global webhooks (no `instance_id`). 400 `INSTANCE_ID_REQUIRED` if missing. | | `status` | string | `all` | Filter mode: `all`, `success` (latest attempt 2xx), `failure` (latest attempt non-2xx), `stuck` (hard-failed: `permanent_4xx` + ≥3 attempts). 400 `INVALID_STATUS` for other values. | | `hours` | int | 24 | Window in hours (max 168 = 7 days). Ignored when `since` is set. | | `since` | RFC3339 | — | Explicit lower-bound cutoff. Overrides `hours`. | | `page` | int | 1 | Page number (1-based). | | `limit` | int | 50 | Page size (max 200). | **Response 200:** ```json { "data": [ { "log_id": 47213, "webhook_id": 7, "webhook_url": "https://app.exemplo.com/webhook", "event_id": "evt-abc-123", "event_type": "message.received", "status_code": 200, "response": "", "attempts": 2, "success": true, "error_class": "", "is_hard_failed": false, "has_payload_raw": true, "created_at": "2026-05-03T10:39:45Z" } ], "total": 75, "page": 1, "limit": 50 } ``` **Field meanings:** - `success`: latest attempt for the event delivered (HTTP 2xx). - `is_hard_failed`: latest attempt's `error_class` is `permanent_4xx` AND `attempts >= 3` (event hit `SkipRetry` and won't auto-recover). - `has_payload_raw`: `payload_raw` is still on disk so `POST /v1/webhooks/retry-bulk` with this `log_id` can replay the delivery. `false` when the row passed the 14-day retention. - `attempts`: highest attempt number (e.g. `4` means up to 4 delivery attempts have been made; for the same event with multiple retries, only ONE row is returned, never four). ### GET /v1/webhooks/events Returns the unified events feed for **the caller's entire company**, aggregated by `event_id` (one row per event with the latest delivery attempt's state). Same shape and aggregation rule as `/v1/webhooks/instance-events`, but **does not require `instance_id`** — it scopes across every webhook of the company. Powers the **Entrega** tab on the consolidated webhooks view with filter chips for status (Todos / Sucesso / Falha / Travado). **Auth:** JWT or API key (any role). Scoped to the caller's company. **Aggregation rule:** identical to `instance-events` — rows grouped by `event_id`, latest delivery attempt determines the row state, `attempts` is the highest attempt number observed. **Query params:** | Param | Type | Default | Meaning | |---|---|---|---| | `status` | string | `all` | Filter mode: `all`, `success` (latest attempt 2xx), `failure` (latest attempt non-2xx), `stuck` (hard-failed: `permanent_4xx` + ≥3 attempts). 400 `INVALID_STATUS` for other values. | | `hours` | int | 24 | Window in hours (max 168 = 7 days). Ignored when `since` is set. | | `since` | RFC3339 | — | Explicit lower-bound cutoff. Overrides `hours`. | | `page` | int | 1 | Page number (1-based). | | `limit` | int | 50 | Page size (max 200). | **Response 200:** Same shape as `/v1/webhooks/instance-events`. ```json { "data": [ { "log_id": 47213, "webhook_id": 7, "webhook_url": "https://app.exemplo.com/webhook", "event_id": "evt-abc-123", "event_type": "message.received", "status_code": 200, "response": "", "attempts": 2, "success": true, "error_class": "", "is_hard_failed": false, "has_payload_raw": true, "created_at": "2026-05-03T10:39:45Z" } ], "total": 75, "page": 1, "limit": 50 } ``` **When to use which endpoint:** - `/v1/webhooks/events` — company-wide audit; default for the consolidated Entrega tab. Use when answering "como está minha entrega de webhooks no geral?" - `/v1/webhooks/instance-events` — same shape, narrowed to one instance. Use when debugging a single instance. - `/v1/webhooks/stuck` — only hard-failed events for the dashboard widget badge / cross-instance triage. ### GET /v1/webhooks/instance-metrics Aggregate webhook delivery metrics scoped to a single instance over a time window. Powers the StatCards on the instance detail Webhooks tab (`/instances/?tab=webhooks`). The aggregation includes deliveries from BOTH webhooks scoped to the instance AND global webhooks (no `instance_id`) — global webhooks fire for every instance, so their deliveries are legitimately part of the instance's webhook health. **Auth:** JWT or API key (any role). Scoped to the caller's company. **Query params:** | Param | Type | Default | Meaning | |---|---|---|---| | `instance_id` | string | **required** | The instance to scope to. 400 `INSTANCE_ID_REQUIRED` if missing. | | `hours` | int | 24 | Window in hours (max 168 = 7 days). | **Response 200:** ```json { "instance_id": "f35157b4-46ae-4bdb-8f51-4870bcb5bf9c", "period_hours": 24, "total": 1284, "successes": 1247, "failures": 37, "success_rate": 97.12, "stuck_pending": 3, "configured_webhooks": 4, "active_webhooks": 3 } ``` **Field meanings:** - `total` / `successes` / `failures`: delivery attempt counts in the window. - `success_rate`: percentage 0-100. Defaults to 100 when there are zero deliveries (no failures = saudável). - `stuck_pending`: distinct event_ids with at least one failure in the window AND no later success row for the same event_id (i.e. wake-up retry hasn't recovered them yet — see lesson #19). - `configured_webhooks`: total webhooks applicable to the instance (scoped + global). - `active_webhooks`: subset of `configured_webhooks` where `active=true`. **Response 200:** `{ data, total, page, limit }`. Each item is a `DeliveryLog` (same shape as the per-webhook endpoint). ### GET /v1/webhooks/metrics Per-webhook health metrics scoped to the caller's company over a time window. Powers the **Dashboard tab** on `/webhooks` for every authenticated user. Tenant callers see only their own webhooks; superadmin sees ALL webhooks across companies — same handler powers both surfaces. **Auth:** JWT or API key (any role with access to `/v1/webhooks` — owner / admin). **Query params:** | Param | Type | Default | Meaning | |---|---|---|---| | `hours` | int | 24 | Window in hours (max 168 = 7 days). | **Response 200:** ```json { "period_hours": 24, "webhooks": [ { "webhook_id": 21, "url": "https://meu-crm.example.com/webhook", "company_id": 9, "company_name": "Catcher", "instance_id": "f35157b4-46ae-4bdb-8f51-4870bcb5bf9c", "instance_name": "Catcher 3", "total": 1284, "successes": 1247, "failures": 37, "success_rate": 97.12 } ] } ``` **Field meanings:** - One entry per webhook applicable to the caller — including webhooks with zero deliveries in the window (so the tenant can see ALL their webhooks at a glance, including newly-created ones). - `success_rate`: 0-100, defaults to 100 when there are zero deliveries. - `company_name` / `instance_name`: enriched server-side from the master + tenant DBs. May be empty when the row is older than the corresponding entity rename. ### GET /v1/webhooks/company-delivery-logs Recent delivery logs for the caller's company (or all companies when superadmin), with optional filters. Powers the **"Falhas recentes" list** on the Dashboard tab. Mirror of `/v1/admin/delivery-logs` but tenant-scoped. **Auth:** JWT or API key (any role with access to `/v1/webhooks`). **Query params (all optional):** | Param | Type | Default | Meaning | |---|---|---|---| | `success` | bool | — | When set, narrows to delivered (`true`) or failed (`false`) attempts. | | `event_type` | string | — | Narrows to a specific event type (e.g. `message.received`). | | `webhook_id` | int | — | Narrows to a specific webhook. Tenant callers are rejected (empty result) if the webhook does not belong to their company. | | `since` | RFC3339 | now-24h | Earliest `created_at` cutoff. | | `page` / `limit` | int | 1 / 50 | Pagination. Max `limit=100`. | **Response 200:** `{ data, total, page, limit }`. Each item carries the delivery log fields plus the joined `webhook_url`, `company_id`, and `company_name` (only populated for superadmin callers, since tenants always know their own company). **Tenant-scoping invariant:** a tenant caller can never see a delivery log whose webhook belongs to another company. Even an explicit `?webhook_id=X` pointing at a foreign webhook returns an empty page (not a 4xx — so the cross-company existence of the webhook isn't leaked). ### Webhook detail endpoints (`/v1/webhooks/{webhookId}`) Since 2026-05-11 the per-webhook endpoints (`GET`, `PATCH`, `DELETE`, `GET /logs`, `POST /logs/{logId}/retry`) accept **cross-company access for superadmin callers**, mirroring the existing escape hatch on `GET /v1/webhooks` for the instance detail surface. Regular tenant callers remain scoped to their own company (404 on cross-company IDs). This unifies the WebhookDetailView at `/webhooks/:id` so the same URL works for both the tenant (their own webhook) and the superadmin (any webhook on the platform). The dedicated `/v1/admin/webhooks/{id}` mirror endpoints are still available for legacy callers.