# Catcher — WhatsApp API · manual completo > Provedora de Tecnologia oficial da Meta: a API oficial do WhatsApp (WhatsApp Business Platform / Cloud API) para desenvolvedores e agentes de IA, com conformidade e escala, templates aprovados, 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_...). 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://provider.catcher.one/docs). Índice curto: https://provider.catcher.one/llms.txt --- # Catcher API — Documentação > **Base URL:** `https://api.catcher.one` > **Versão:** v1 > **Formato:** JSON (`Content-Type: application/json`) A **Catcher** é a API oficial do WhatsApp — a **WhatsApp Business Platform (Meta Cloud API)**, na qual a Catcher opera como **Provedora de Tecnologia oficial da Meta**. Você conecta seu número WhatsApp Business, envia e recebe mensagens via REST, e observa cada evento em tempo real por Webhooks e SSE/WebSocket. Esta referência cobre 100% dos métodos e eventos da plataforma oficial. Recursos exclusivos do canal oficial documentados aqui: **templates aprovados (HSM)** com botões e variáveis, **métricas e custos** (quality rating, volume, conversas cobradas por categoria), **indicador "digitando…"** e **marcação de leitura** nativos, além de **anti-spam inteligente** que protege o quality rating do seu número. --- ## Í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. [Conexões WhatsApp](#6-conexões-whatsapp) 7. [Mensagens](#7-mensagens) 8. [Templates (HSM)](/docs/templates-hsm) 9. [Métricas & Insights](#9-métricas-insights) 10. [Mensagens Agendadas](#10-mensagens-agendadas) 11. [Mídia (Upload/Download)](#11-mídia-uploaddownload) 12. [Chats](#12-chats) 13. [Contatos](#13-contatos) 14. [Presença, Leitura e "Digitando"](#14-presença-leitura-e-digitando) 15. [Fila de Mensagens](#15-fila-de-mensagens) 16. [Webhooks](/docs/webhooks) 17. [Eventos em Tempo Real (SSE/WebSocket)](#16-webhooks-em-tempo-real-ssewebsocket) 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. [Identificadores e Correlação de Eventos](#21-identificadores-e-correlação-de-eventos) 22. [Billing](#22-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` - **Revalidacao de empresa:** em cada request autenticada com JWT, 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 | ### 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 | | Mídia | x | x | x | | Fila | x | x | - | | Webhooks | x | x | - | ### Rate Limiting - Baseado em Redis, por empresa - Limite configuravel por plano (padrão: 60 req/min) - 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_*`. --- ## 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" } ``` **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", "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. Conexões WhatsApp Uma conexão (instância) representa um número WhatsApp Business vinculado à sua conta. No canal oficial, você conecta o número da sua **WhatsApp Business Account (WABA)** — pelo fluxo guiado **Embedded Signup** da Meta ou informando as credenciais diretamente. Depois de conectada, a conexão envia e recebe mensagens, e cada evento chega por Webhooks e SSE. O `channel` da conexão é `whatsapp_official`. O provedor interno não é exposto — você trabalha sempre com a mesma API REST. ### Conectar o número oficial (Embedded Signup) O caminho recomendado. O cliente autoriza a Catcher na tela oficial da Meta e o número é vinculado sem sair do fluxo. ``` GET /v1/meta/embedded-signup/config POST /v1/instances/{instanceId}/official/embedded-signup ``` `GET .../config` retorna o bootstrap público (`app_id`, `config_id`, `graph_version`, `enabled`) para iniciar o `FB.login()`. Após o retorno, envie o `code` + os `waba_id` / `phone_number_id` capturados: ```json { "code": "", "waba_id": "...", "phone_number_id": "..." } ``` A Catcher troca o `code` por um token permanente no servidor, inscreve a WABA no webhook e conecta. Retorna o estado mascarado da credencial (o token nunca é exposto de volta). ### Conectar informando credenciais Alternativa direta: informe o `phone_number_id`, o `waba_id` e um token permanente de System User da WABA. ``` POST /v1/instances/{instanceId}/official/credentials ``` ```json { "waba_id": "...", "phone_number_id": "...", "access_token": "" } ``` A Catcher valida o token na Graph API, inscreve a WABA no webhook, cifra o token (AES-GCM) e conecta. O estado é consultável por: ``` GET /v1/instances/{instanceId}/official/credentials ``` ```json { "configured": true, "waba_id": "...", "phone_number_id": "...", "token_hint": "…ABCD", "display_phone_number": "+55 41 6349-0888", "verified_name": "Catcher", "quality_rating": "GREEN", "status": "CONNECTED", "reauth_required": false } ``` Quando o token expira ou é revogado, `reauth_required` fica `true` e a conexão emite o evento `official.reauth_required` — reconecte pelo Embedded Signup ou informe um token novo. ### POST /v1/instances Cria uma nova conexão (o vínculo do número é feito em seguida pelos endpoints oficiais acima). **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 --- ### 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 reinicia a conexão — e uma alteração puramente de nome 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, "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`. | | `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, "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` | | `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`, `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)`. - Template: hash de `body + footer`. - Reaction fica fora dessa regra (opera em mensagem existente). - **Reset por inbound:** quando o contato responde (qualquer mensagem inbound), 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 mensagem inbound zera o contador. 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 Ativa a conexão da instância com a WhatsApp Business Platform usando as credenciais oficiais já vinculadas (ver Embedded Signup / credenciais acima). **Auth:** Owner, Admin **Resposta 200:** Objeto de instância atualizado. --- ### 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. --- ### DELETE /v1/instances/{instanceId} Remove a instância permanentemente. **Auth:** Owner, Admin **Resposta 204:** Sem corpo. --- ### Chamadas de voz (Calling API) No canal oficial, o número WhatsApp Business pode receber chamadas de voz pela **Calling API** da Meta. Uma chamada recebida chega como o evento [`call.received`](/docs/webhooks) (state `ringing`) e o encerramento como [`call.ended`](/docs/webhooks). O SDP offer nunca é exposto — só o sinal. #### POST /v1/instances/{instanceId}/calling Liga ou desliga a recepção de chamadas de voz no número oficial. **Auth:** Owner, Admin **Request:** ```json { "enabled": true } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|:-----------:|-----------| | `enabled` | bool | sim | `true` habilita chamadas de voz no número; `false` desabilita | **Resposta 200:** estado atualizado da instância. #### POST /v1/instances/{instanceId}/calls/{callID}/reject Rejeita uma chamada de voz que está tocando (`ringing`). `callID` é o `call_id` recebido no evento `call.received`. **Auth:** Todos autenticados **Resposta 200:** confirmação da rejeição. #### POST /v1/instances/{instanceId}/calls/{callID}/hangup Encerra uma chamada de voz em andamento. `callID` é o `call_id` da chamada. **Auth:** Todos autenticados **Resposta 200:** confirmação do encerramento. --- ## 7. Mensagens Todas as operações assíncronas desta seção (`send`, `forward`, `delete`, `edit`) são processadas em fila. A API retorna `202 Accepted` com o `message_id` da Catcher (UUID pré-gerado na aceitação da request), `idempotency_key` e `task_id` para rastreamento e deduplicação segura de retries do cliente. ### O que o canal oficial suporta A plataforma oficial da Meta (Cloud API) suporta os tipos abaixo. Envie sempre pelo mesmo endpoint REST — a Catcher roteia para a Cloud API. | Suportado | Não suportado pela Cloud API | |---|---| | Texto, texto com preview de link | Enquete (`poll`) | | Imagem, vídeo, áudio (PTT), documento, sticker | View-once, PTV, GIF | | Reação | Evento (`event`) | | Localização | Status / Stories | | Contato (cartão) | Encaminhar, apagar, editar mensagem | | Template aprovado (HSM) — ver [Templates](/docs/templates-hsm) | Botões interativos "crus" (use um template com botões) | Um tipo não suportado retorna `501` com uma mensagem clara (`… is not supported for the official channel`) — não é falha da sua integração, é uma limitação da Cloud API. **Fora da janela de 24h**, um envio livre retorna `409 OUTSIDE_SERVICE_WINDOW`; use um [template aprovado](/docs/templates-hsm) para reabrir a conversa. > Os toques em botão/lista de um template chegam de volta como o evento > [`message.interactive_reply_received`](/docs/webhooks) com o `selected_id` e o > texto selecionado — pronto para automação. ### 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` ### 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`. **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) | — | 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. ### 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`, `template`) 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; 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. 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). --- ### Áudio: dois endpoints, dois jeitos O envio de áudio tem **dois endpoints separados** — escolha pelo tipo de mensagem, sem depender de flags: | Endpoint | Renderiza como | Waveform | Recibo `played` | Use quando | |---|---|:---:|:---:|---| | **`POST .../messages/voice`** | **mensagem de voz (PTT)** | ✅ ondinha | ✅ | quer o áudio "de voz", tipo gravar segurando o microfone | | **`POST .../messages/audio`** | **arquivo de áudio** | ❌ player comum | ❌ | quer mandar um arquivo de áudio (música, gravação, anexo) | Ambos aceitam o mesmo corpo (`to` + `media_url` **ou** `media_id` **ou** `file`). --- ### POST /v1/instances/{instanceId}/messages/voice Envia uma **mensagem de voz (PTT)** — renderizada com **waveform (ondinha) + ícone de play**, e o toque do destinatário gera o recibo `message.played`. O áudio é sempre convertido para **OGG Opus** (mono, 16 kHz, 32 kbps) — formato exigido pelo WhatsApp para voz; uma waveform de 64 bytes é gerada automaticamente. É o jeito de mandar "um áudio de voz". **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 áudio (qualquer formato — convertido para OGG Opus) | | `media_id` | string | sim* | ID de mídia pré-enviado | | `file` | file | sim* | Arquivo direto via `multipart/form-data` | | `quoted_id` | string | não | ID da mensagem respondida (UUID Catcher ou hex WhatsApp) | | `mentions` | []string | não | JIDs mencionados | > *Enviar `media_url`, `media_id` OU `file` (um dos três é obrigatório). > Ciclo de vida: `sent → delivered → read → played`. O `played` chega quando o destinatário **toca** o áudio (requer confirmações de leitura habilitadas no aparelho dele). --- ### POST /v1/instances/{instanceId}/messages/audio Envia um **arquivo de áudio** — um player de áudio comum, **sem** waveform e **sem** recibo `played`. Para uma **mensagem de voz** (ondinha + `played`), use **`/messages/voice`**. Os bytes vão como estão, sem conversão. **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 | Compatibilidade retroativa. **Padrão `false`** (arquivo de áudio). `true` força uma **mensagem de voz** (equivalente a `/messages/voice`). Prefira o endpoint `/messages/voice`. | | `quoted_id` | string | não | ID da mensagem respondida (UUID Catcher 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/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}/official/send-interactive Envia uma **mensagem interativa nativa** da Cloud API: botões de resposta (reply buttons), lista, botão CTA URL, pedido de localização, endereço, ou um **Flow**. Só disponível no canal oficial (`whatsapp_official`). **Auth:** Todos autenticados · **Header:** `Idempotency-Key` obrigatório **Request:** ```json { "to": "5541999998888", "kind": "cta_url", "interactive": { "type": "cta_url", "body": { "text": "Confira nosso catálogo completo" }, "action": { "name": "cta_url", "parameters": { "display_text": "Ver catálogo", "url": "https://loja.exemplo.com" } } } } ``` | Campo | Tipo | Obrigatório | Descrição | |-------|------|:-----------:|-----------| | `to` | string | sim | Destinatário (telefone ou JID) | | `kind` | string | não | Dica do tipo: `button`, `list`, `cta_url`, `location_request`, `address`, `flow` | | `interactive` | object | sim | O objeto `interactive` da Cloud API (`type` + `body` + `action`), montado por você | **Mais exemplos** (o objeto `interactive` segue o schema da Cloud API): ```json // Reply buttons (até 3) — o tap volta como interactive_reply_received { "to": "5541999998888", "kind": "button", "interactive": { "type": "button", "body": { "text": "Confirma o agendamento?" }, "action": { "buttons": [ { "type": "reply", "reply": { "id": "opt_sim", "title": "Sim" } }, { "type": "reply", "reply": { "id": "opt_nao", "title": "Não" } } ] } } } // Lista/menu (rows agrupadas em seções) { "to": "5541999998888", "kind": "list", "interactive": { "type": "list", "header": { "type": "text", "text": "Menu" }, "body": { "text": "Escolha uma opção" }, "action": { "button": "Ver opções", "sections": [ { "title": "Planos", "rows": [ { "id": "row_start", "title": "Plano Start", "description": "Para começar" } ] } ] } } } // Pedido de localização — o toque abre o "compartilhar localização" do WhatsApp { "to": "5541999998888", "kind": "location_request", "interactive": { "type": "location_request_message", "body": { "text": "Compartilhe sua localização 📍" }, "action": { "name": "send_location" } } } ``` Retorna `202` com `message_id` (UUID Catcher) + `task_id` + `idempotency_key`. **O que volta quando o contato interage:** | Interação do contato | Evento(s) que você recebe | |---|---| | Tap em reply button / item de lista | `message.received` (type `interactive`, `content` = label tocado) **+** [`message.interactive_reply_received`](/docs/webhooks) (`reply_type` `button_reply`/`list_reply`, `selected_id`, `display_text`/`title`/`description`) | | Submit de um Flow | `message.interactive_reply_received` com `reply_type: "nfm"` + o JSON da resposta do formulário | | Resposta a `location_request` | `message.received` type `location` com **`latitude`/`longitude`**, `location_address` (quando o lugar é nomeado), nome em `content`, e `quoted_msg_id` = UUID do pedido — correlacione pedido→resposta por ele | | Clique no botão CTA URL | Nenhum evento (o link abre no navegador do contato — não gera reply) | > **Carrossel:** um carrossel é enviável pelo endpoint de template ([`POST .../official/send-template`](#enviar-um-template-aprovado)) usando componentes `carousel` no template aprovado. > **Flow:** requer um Flow **publicado** no Flow Builder da sua WABA (o `interactive` referencia `flow_id`/`flow_cta`). **Address:** disponibilidade restrita por região pela Meta. --- ## 8. Templates (HSM) Templates (HSM — Highly Structured Messages) são modelos de mensagem aprovados pela Meta. No canal oficial, **um template aprovado é a única forma de iniciar uma conversa fora da janela de 24h** (após uma mensagem do contato, você tem 24h para responder livremente; depois disso, só templates). Um template pode ter cabeçalho, corpo com variáveis `{{1}}`, rodapé e botões (resposta rápida, link, telefone). Ciclo de vida: você cria o template → a Meta revisa (`PENDING`) → aprova (`APPROVED`) ou rejeita (`REJECTED`). Você acompanha a transição pelo webhook [`official.template_status`](/docs/webhooks). Só templates `APPROVED` podem ser enviados. ### Listar templates ``` GET /v1/instances/{instanceId}/templates ``` Retorna os templates da sua WABA com o status de aprovação. ```json { "templates": [ { "id": "123456789", "name": "confirmacao_pedido", "language": "pt_BR", "status": "APPROVED", "category": "UTILITY", "components": [ { "type": "HEADER", "format": "TEXT", "text": "Olá {{1}}" }, { "type": "BODY", "text": "Seu pedido {{1}} foi confirmado e chega em {{2}} dias úteis." }, { "type": "BUTTONS", "buttons": [{ "type": "QUICK_REPLY", "text": "Acompanhar pedido" }] } ] } ] } ``` ### Criar template ``` POST /v1/instances/{instanceId}/templates ``` O nome deve ser `snake_case` (minúsculas, números e `_`). A categoria é `MARKETING`, `UTILITY` ou `AUTHENTICATION`. O array `components` segue o formato da Graph API da Meta. | Componente | Campos | |---|---| | `HEADER` (opcional) | `format: "TEXT"`, `text`. Com variável, exige `example.header_text: ["exemplo"]`. Aceita no máximo **uma** variável (`{{1}}`). | | `BODY` (obrigatório) | `text` com `{{1}}`, `{{2}}`… Com variáveis, exige `example.body_text: [["ex1","ex2"]]` (array aninhado). | | `FOOTER` (opcional) | `text`. | | `BUTTONS` (opcional, até 3) | `buttons[]`: `QUICK_REPLY` (`text`), `URL` (`text`, `url`), `PHONE_NUMBER` (`text`, `phone_number`). | **Request** ```json { "name": "confirmacao_pedido", "language": "pt_BR", "category": "UTILITY", "components": [ { "type": "HEADER", "format": "TEXT", "text": "Olá {{1}}", "example": { "header_text": ["Ana"] } }, { "type": "BODY", "text": "Seu pedido {{1}} foi confirmado e chega em {{2}} dias úteis.", "example": { "body_text": [["#12345", "3"]] } }, { "type": "FOOTER", "text": "Responda SAIR para não receber mais" }, { "type": "BUTTONS", "buttons": [{ "type": "QUICK_REPLY", "text": "Acompanhar pedido" }] } ] } ``` **Response `201`** — o template entra em revisão (`PENDING`); acompanhe pelo webhook `official.template_status`. ### Remover template ``` DELETE /v1/instances/{instanceId}/templates/{name} ``` ```json { "deleted": true, "name": "confirmacao_pedido" } ``` ### Enviar um template aprovado ``` POST /v1/instances/{instanceId}/official/send-template ``` Requer `Idempotency-Key`. É a única mensagem que abre uma conversa fora da janela de 24h. Fora da janela, um envio livre (`/messages/text`) retorna `409 OUTSIDE_SERVICE_WINDOW` — use este endpoint. **Request** ```json { "to": "5541999998888", "template_name": "confirmacao_pedido", "language": "pt_BR", "components": [ { "type": "header", "parameters": [{ "type": "text", "text": "Ana" }] }, { "type": "body", "parameters": [{ "type": "text", "text": "#12345" }, { "type": "text", "text": "3" }] } ] } ``` **Response `202`** ```json { "status": "queued", "message_id": "", "task_id": "...", "idempotency_key": "..." } ``` Os parâmetros preenchem as variáveis `{{n}}` do template na ordem. O `message_id` retornado é o UUID Catcher que aparecerá nos webhooks `message.sent` / `message.delivered` / `message.read`. > **Opt-out de marketing:** enviar um template `category:"marketing"` para um > contato que optou por sair (evento [`official.marketing_optout`](/docs/webhooks) > com `opted_out=true`) retorna `409 BLOCKED_MARKETING_OPTOUT`. Templates > `utility` e `authentication` **sempre** passam, independentemente do opt-out. --- ## 9. Métricas & Insights O canal oficial expõe as métricas da WhatsApp Business Management API da Meta: qualidade do número, volume de envios, taxa de entrega e **custo das conversas cobradas por categoria**. Ideal para dashboards de operação e custo. ### Insights consolidados ``` GET /v1/instances/{instanceId}/official/insights?days=30 ``` `days` aceita 1 a 90 (padrão 30). Cada seção é best-effort — se uma métrica não puder ser carregada, ela vira um aviso em `warnings` e o resto continua (nunca falha o dashboard inteiro). ```json { "period": { "start": 1781748265, "end": 1784340265, "days": 30 }, "quality": { "quality_rating": "GREEN", "messaging_limit_tier": "TIER_1K", "name_status": "AVAILABLE_WITHOUT_REVIEW", "display_phone_number": "+55 41 6349-0888", "verified_name": "Catcher" }, "messaging": { "sent": 1240, "delivered": 1198, "points": [{ "start": 1784257200, "end": 1784343600, "sent": 42, "delivered": 41 }] }, "cost": { "total_cost": 18.60, "total_conversations": 74, "by_category": [ { "category": "MARKETING", "conversations": 40, "cost": 14.20 }, { "category": "UTILITY", "conversations": 34, "cost": 4.40 } ], "points": [ ] }, "warnings": [ ] } ``` | Campo | Significado | |---|---| | `quality.quality_rating` | `GREEN` (alta) / `YELLOW` (média) / `RED` (baixa). Reflete a satisfação dos destinatários. | | `quality.messaging_limit_tier` | Limite de contatos únicos por dia: `TIER_250`, `TIER_1K`, `TIER_10K`, `TIER_100K`, `TIER_UNLIMITED`. | | `messaging.sent` / `delivered` | Volume no período. A taxa de entrega é `delivered / sent`. | | `cost.by_category` | Conversas cobradas e custo por categoria (`MARKETING`, `UTILITY`, `AUTHENTICATION`, `SERVICE`). Conversas de atendimento na janela de serviço não geram custo. | ### Analytics por template ``` GET /v1/instances/{instanceId}/official/template-analytics?days=30&template_ids=T1,T2 ``` Funil por template (enviados → entregues → lidos) e cliques por botão. Requer que a WABA tenha o analytics de template habilitado na Meta (opt-in único); sem isso, retorna um `400` limpo. ```json { "period": { "start": 1781748265, "end": 1784340265, "days": 30 }, "data_points": [ { "template_id": "T1", "sent": 400, "delivered": 388, "read": 300, "clicked": [{ "type": "quick_reply_button", "button_content": "Sim", "count": 120 }] } ] } ``` ### Uso da conexão ``` GET /v1/instances/{instanceId}/official/usage ``` Rollup de medição da conexão na janela recente (mensagens enviadas/recebidas, templates enviados, conversas). ```json { "instance_id": "", "channel": "whatsapp_official", "period_days": 30, "messages_sent": 1240, "messages_received": 980, "templates_sent": 74, "conversations": 74 } ``` --- ## 10. 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`, `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" } ``` --- ## 11. 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). **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}`) | --- ## 12. Chats ### GET /v1/instances/{instanceId}/chats Lista chats da instância. **Auth:** Todos autenticados 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": "554137984905@s.whatsapp.net", "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "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 (`@s.whatsapp.net`) | | `phone` | string | Telefone extraido do chat | | `is_deleted` | bool | `true` se a mensagem foi revogada (status=`deleted`) | | `sender_jid` | string | JID de quem enviou | | `message_type` | string | `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `reaction`, `template` | | `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` | datetime | Timestamps do ciclo de vida. `played_at` preenchido quando o destinatário toca áudio/vídeo | | `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 } ``` --- ## 13. 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 `+`) ou phone JID (`554199...@s.whatsapp.net`). 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" } ] } ``` **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 | **Variantes conhecidas** de JID para este contato (`type=phone`). O `phone` é sempre o número puro. | > **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. **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 (servida 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 `/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/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. --- ### 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 flag de agenda (`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). --- ### 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, armazena no S3/R2 e expõe apenas URL controlada 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. > 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", "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 | | `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. > No canal oficial, bloqueio e desbloqueio usam a **Block Users API** da Meta (body `{ "contact_id": "..." }`). **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 } ``` --- ## 14. Presença, Leitura e "Digitando" No canal oficial, a comunicação fica mais natural: a Catcher expõe o indicador **"digitando…"** e a **marcação de leitura** da Cloud API. Você pode acioná-los por request, ou deixar a Catcher cuidar disso automaticamente. - **"Digitando…"** — `POST .../presence` com `presence: "composing"` mostra o indicador de digitação ao contato (até ~25s, ou até a próxima mensagem). No canal oficial, o indicador só aparece em resposta a uma mensagem recebida. - **Marcar como lido** — `POST .../mark-read` marca as mensagens recebidas como lidas (o "check azul" para o remetente). - **Auto-read** — ligue `auto_read_messages` nas configurações da conexão e cada mensagem recebida é marcada como lida automaticamente. - **Humanize** — ligue `humanize_enabled` e, ao responder, a Catcher mostra "digitando…" por um instante proporcional ao tamanho do texto antes de enviar — a conversa parece humana. ### POST /v1/instances/{instanceId}/presence Define a presença / mostra "digitando…". No canal oficial, `composing` aciona o indicador de digitação da Cloud API (ancorado na última mensagem recebida do chat); os demais valores retornam sucesso sem efeito (a Cloud API não tem presença geral de online/offline). **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"] } ``` --- ## 15. 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-..." } ``` --- ## 16. 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` e `message.played`, 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 | Descrição | Persistido | |--------|-----------|:----------:| | `message.received` | Mensagem recebida de um contato | Sim (tabela `messages`) | | `message.sent` | Mensagem que você enviou foi aceita pela Meta | Sim (tabela `messages`) | | `message.delivered` | Mensagem entregue no aparelho do contato (double check) | Sim (atualiza `delivered_at`) | | `message.read` | Mensagem lida pelo contato (blue check) | Sim (atualiza `read_at`) | | `message.played` | Áudio (voice note) ouvido pela primeira vez no aparelho do contato | Sim (atualiza `played_at`) | | `message.delivery_failed` | A Meta não conseguiu entregar a mensagem (motivo no payload) | Sim (atualiza `status=failed`) | | `message.reaction_received` | Um contato reagiu a uma mensagem com emoji | Não cria linha; evento operacional | | `message.interactive_reply_received` | O contato tocou num botão/item de lista ou submeteu um Flow (`nfm`) | Não cria linha; evento operacional | | `message.order_received` | O contato fez um pedido do catálogo (commerce) | Não cria linha; evento operacional | | `call.received` | Chamada de voz recebida (Calling API) | Sim (tabela `call_logs`) | | `call.ended` | Chamada de voz encerrada | Não cria linha; evento operacional | | `official.template_status` | A Meta aprovou/rejeitou um template HSM | Não | | `official.quality_update` | Mudou o quality rating do número ou o tier de limite | Não | | `official.reauth_required` | O token da WABA expirou/foi revogado — reconecte | Não | | `official.marketing_optout` | O contato optou por sair (ou voltar) das mensagens de marketing | Não | | `official.account_alert` | Alerta da Meta sobre a conta WABA | Não | | `official.account_review` | A conta WABA entrou em revisão da Meta | Não | | `official.capability_update` | Mudança de capabilities/limites do número | Não | | `official.template_quality` | Mudou o quality score de um template | Não | | `official.template_components` | Componentes de um template foram atualizados pela Meta | Não | | `official.template_category` | A Meta reclassificou a categoria de um template | Não | | `official.phone_name_update` | Mudou o nome de exibição aprovado do número | Não | | `official.security_update` | Aviso de segurança da conta/número | Não | | `connection.update` | Mudança de status da conexão oficial | Sim (tabela `instances`) | | `contact.update` | Mudança de perfil do contato (foto, nome) | Sim (tabela `contact_events`) | | `contact.identity_changed` | O contato trocou de número de telefone | Não cria linha; evento operacional | | `contact.temperature_changed` | O engajamento de um contato mudou de faixa (anti-spam) | Não | | `anti_spam.blocked` | Um envio foi bloqueado pela proteção anti-spam | Não | | `whatsapp.policy_warning` | A Meta emitiu um aviso de política/anti-abuso | Não | | `instance.banned` | O número recebeu restrição da Meta | Sim (atualiza `ban_expiry`, `ban_reason`) | | `instance.offline` | Conexão oficial fora do ar há 15min ou mais | Sim (campo `offline_alert_level=warning`) | | `instance.critical_offline` | Conexão fora do ar há 6h ou mais; dispara email ao dono | Sim (campo `offline_alert_level=critical`) | | `instance.recovered` | A conexão voltou após ficar offline | Sim (limpa `offline_alert_level`) | | `instance.deleted` | A conexão foi removida | Sim (soft-delete da instância) | | `*` | 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). #### Eventos exclusivos do canal oficial Além de todos os eventos de mensagem abaixo, o canal oficial emite: **`message.interactive_reply_received`** — o contato tocou em um botão ou item de lista de um template/mensagem interativa. Traz a seleção estruturada para automação, além de um `message.received` com o texto do botão como conteúdo. ```json { "chat": "554199990000@s.whatsapp.net", "phone": "554199990000", "sender": "554199990000@s.whatsapp.net", "reply_type": "button_reply", "selected_id": "TRACK_ORDER", "selected_display_text": "Acompanhar pedido", "title": "", "description": "", "timestamp": 1784340265 } ``` `reply_type` é `button_reply`, `list_reply` ou `nfm` (resposta de um **Flow**). Para `list_reply`, `title` e `description` trazem o item escolhido. Para `nfm`, o payload traz `native_flow_name` + `native_flow_params_json` (o JSON do formulário submetido pelo contato). **`official.template_status`** — a Meta mudou o status de aprovação de um template (`PENDING` → `APPROVED` / `REJECTED`). ```json { "template_name": "confirmacao_pedido", "language": "pt_BR", "status": "APPROVED", "reason": "" } ``` **`official.quality_update`** — mudou o quality rating do número ou o tier de limite de mensagens. ```json { "display_phone_number": "+55 41 6349-0888", "quality_rating": "GREEN", "messaging_limit_tier": "TIER_1K" } ``` **`official.reauth_required`** — o token da WABA expirou ou foi revogado. A conexão fica `DISCONNECTED` até você reconectar (Embedded Signup ou token novo). ```json { "instance_id": "84c2e480-...", "reason": "token expired or revoked" } ``` Status de entrega das mensagens que você envia (`message.sent`, `message.delivered`, `message.read`, `message.delivery_failed`) chegam normalmente pelos eventos abaixo, com o `message_id` (UUID Catcher) para correlação. #### 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}`. Ecos das mensagens que você mesmo enviou não geram esse evento. ```json { "phone": "554192464230", "from": "554192464230@s.whatsapp.net", "chat": "554192464230@s.whatsapp.net", "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, "push_name": "Joao Silva" } ``` **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" }, "is_forwarded": false, "timestamp": 1741360245, "push_name": "Patricia" } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (ex: `"554192464230"`) | | `from` | string | JID do remetente (`@s.whatsapp.net`) | | `chat` | string | JID do chat (igual a `from` em DM) | | `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 | | `content` | string | Texto, caption, ou emoji (para reactions) | | `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` | | `latitude` / `longitude` | float64 | Coordenadas — presentes apenas quando `type=location` (localização compartilhada, ex.: resposta a um pedido de localização). O nome do lugar (quando houver) vem em `content` | | `location_address` | string | Endereço da localização compartilhada (apenas `type=location`, quando informado) | | `timestamp` | int64 | Unix timestamp | | `push_name` | string | Nome do remetente no WhatsApp | **Campos de `ad_context`** (todos opcionais, presentes conforme disponível): | 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) | > **`ad_context` sem URL Meta:** quando a mensagem veio de um anúncio Click-to-WhatsApp (Instagram/Facebook), ela carrega `ad_context` (`source_type`, `source_id`, `title`, `body`, `media_type`, `ctwa_clid`) com **apenas texto e tracking**. As URLs de mídia do anúncio e o permalink do post são intencionalmente removidos — a Catcher nunca expõe uma URL de CDN da Meta. Use `title`/`body` para o conteúdo e `ctwa_clid`/`ref` para atribuição de campanha. > **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", "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 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (ex: `"554192464230"`) | | `to` | string | JID do destinatário (`@s.whatsapp.net`) | | `chat` | string | JID do chat (igual a `to` em DM) | | `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/correlação de baixo nível; não substitui o UUID Catcher em `message_ids` | | `type` | string | Tipo da mensagem (text, image, video, audio, document, sticker, location, contact, reaction, template) | | `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 | > **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. --- #### 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", "whatsapp_message_ids": ["3EB0DEF456ABC123", "3EB0DEF456ABC124"], "status": "delivered", "timestamp": 1741360310 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (ex: `"554192464230"`) | | `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 (`@s.whatsapp.net`) | | `sender` | string | JID de quem recebeu (`@s.whatsapp.net`) | | `status` | string | Sempre "delivered" | | `timestamp` | int64 | Unix timestamp da entrega | --- #### 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", "whatsapp_message_ids": ["3EB0DEF456ABC123"], "status": "read", "timestamp": 1741360350 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo (ex: `"554192464230"`) | | `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 (`@s.whatsapp.net`) | | `sender` | string | JID de quem leu (`@s.whatsapp.net`) | | `status` | string | Sempre "read" | | `timestamp` | int64 | Unix timestamp da leitura | --- #### message.played Disparado quando o contato **ouve** pela primeira vez um áudio (voice note) que você enviou. Atualiza `status=played` e `played_at` na tabela `messages`. É a prova de que a mídia foi consumida (o salto `delivered → played`). ```json { "phone": "554192464230", "message_ids": ["550e8400-e29b-41d4-a716-446655440001"], "chat": "554192464230@s.whatsapp.net", "status": "played", "timestamp": 1741360400 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `message_ids` | []string | UUIDs internos do Catcher. Identificadores únicos e estáveis. | | `chat` | string | JID do chat (`@s.whatsapp.net`) | | `status` | string | Sempre "played" | | `timestamp` | int64 | Unix timestamp do play | --- #### 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 --- #### contact.update Disparado quando a foto de perfil, nome ou status de um contato muda. Persistido na tabela `contact_events`. ```json { "phone": "554192464230", "jid": "554192464230@s.whatsapp.net", "action": "picture_change", "value": "j+rE", "timestamp": 1741360700 } ``` | Campo | Tipo | Descricao | |-------|------|-----------| | `phone` | string | Número de telefone sem sufixo | | `jid` | string | JID do contato (`@s.whatsapp.net`) | | `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. - **`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 para garantir visibilidade em code review. --- ### Payloads adicionais de eventos Os eventos abaixo também fazem parte do conjunto de eventos. Os exemplos usam o envelope real `{type,data}`. #### instance.deleted Campos: `instance_id`, `phone`, `timestamp`. ```json { "type": "instance.deleted", "data": { "instance_id": "84c2e480-...", "phone": "554137984905", "timestamp": 1741361650 } } ``` #### message.reaction_received Campos: `chat`, `phone`, `sender`, `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", "envelope_whatsapp_message_id": "3EB0REACTION", "target_whatsapp_message_id": "3EB0TARGET", "target_from_me": true, "emoji": "👍", "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 } } ``` #### 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 } } ``` #### message.order_received Campos: `chat`, `phone`, `sender`, `order_id`, `item_count`, `total_amount_1000`, `currency`, `timestamp`. Evento operacional (não cria linha) — o contato fez um pedido do catálogo (commerce). `total_amount_1000` é o valor total em milésimos da moeda (ex.: `45990` = 45,99 BRL). ```json { "type": "message.order_received", "data": { "chat": "554199990000@s.whatsapp.net", "phone": "554199990000", "sender": "554199990000@s.whatsapp.net", "order_id": "SMB123456", "item_count": 3, "total_amount_1000": 45990, "currency": "BRL", "timestamp": 1741361650 } } ``` #### contact.identity_changed Campos: `phone`, `jid`, `timestamp`. O contato trocou de número de telefone (system message `customer_changed_number`). Use para reconciliar o contato com o novo `jid`. ```json { "type": "contact.identity_changed", "data": { "phone": "554199991111", "jid": "554199991111@s.whatsapp.net", "timestamp": 1741361650 } } ``` #### call.received Campos: `phone`, `from`, `call_id`, `state`, `timestamp`. Chamada de voz recebida (Calling API, evento `connect`). Persistida em `call_logs`. O SDP offer **nunca** é exposto no payload — só o sinal. ```json { "type": "call.received", "data": { "phone": "554199990000", "from": "554199990000@s.whatsapp.net", "call_id": "wacid.ABCD1234", "state": "ringing", "timestamp": 1741361650 } } ``` #### call.ended Campos: `phone`, `from`, `call_id`, `reason`, `duration`, `timestamp`. Chamada de voz encerrada (evento `terminate`). `duration` em segundos (0 se não foi atendida). ```json { "type": "call.ended", "data": { "phone": "554199990000", "from": "554199990000@s.whatsapp.net", "call_id": "wacid.ABCD1234", "reason": "completed", "duration": 42, "timestamp": 1741361650 } } ``` #### official.marketing_optout Campos: `phone`, `from`, `category`, `opted_out`, `value`, `timestamp`. O contato mudou a preferência de marketing (webhook `user_preferences`). `value` é `stop` (opt-out, `opted_out=true`) ou `resume` (opt-in de volta, `opted_out=false`). Envios de template `category:"marketing"` para um contato com `opted_out=true` são bloqueados com `409 BLOCKED_MARKETING_OPTOUT`. ```json { "type": "official.marketing_optout", "data": { "phone": "554199990000", "from": "554199990000@s.whatsapp.net", "category": "marketing_messages", "opted_out": true, "value": "stop", "timestamp": 1741361650 } } ``` #### Eventos de observabilidade da conta (`official.*`) Oito eventos de saúde da conta/número/template usam o **payload genérico** `{ field, detail, timestamp }`, onde `field` é o campo bruto do webhook da Meta e `detail` traz o valor decodificado. Todos **carregam apenas estado da conta, nunca mídia/URL** — use para dashboards de saúde da WABA e alertas operacionais. | `field` do webhook Meta | Evento | |---|---| | `account_alerts` | `official.account_alert` | | `account_review_update` | `official.account_review` | | `business_capability_update` | `official.capability_update` | | `message_template_quality_update` | `official.template_quality` | | `message_template_components_update` | `official.template_components` | | `template_category_update` | `official.template_category` | | `phone_number_name_update` | `official.phone_name_update` | | `security` | `official.security_update` | ```json { "type": "official.template_quality", "data": { "field": "message_template_quality_update", "detail": { "message_template_name": "confirmacao_pedido", "message_template_language": "pt_BR", "new_quality_score": "GREEN" }, "timestamp": 1741361650 } } ``` --- ### Persistencia de eventos Eventos são persistidos no histórico operacional do tenant quando passam pelo pipeline de realtime/webhooks. Eventos operacionais (`connection.update`, `contact.update`, `official.template_status`, `official.quality_update`, `contact.temperature_changed`, `whatsapp.policy_warning`) não criam/alteram linhas 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 ``` | 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) | | `played_at` | Destinatário tocou a mídia (áudio/vídeo) | | `failed_at` | Falha no envio | **Tabelas de eventos:** | Tabela | Evento | Descricao | |--------|--------|-----------| | `messages` | message.received, message.sent, message.delivered, message.read, message.played | Todas as mensagens inbound/outbound com tracking completo | | `contact_events` | contact.update | Mudancas em contatos | --- ## 17. 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 ``` **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). **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); }; ``` --- ## 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`. Informe-o ao suporte para correlacionar com o log do backend. | | `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 | | `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). | | `BLOCKED_MARKETING_OPTOUT` | Template `category:"marketing"` enviado a um contato que fez opt-out de marketing (webhook `official.marketing_optout`). HTTP 409. Templates `utility`/`authentication` sempre passam. | | **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 | ### Correlacionando um erro pelo `trace_id` Quando o frontend recebe um erro, ele também recebe o `trace_id` (no corpo e no header `X-Trace-ID`). Guarde esse valor para reportar ao suporte — ele identifica unicamente a request no backend e acelera o diagnóstico. --- ## 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 - `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. Identificadores e Correlação de Eventos Está seção explica como JIDs 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 | | **RemoteJID** | Phone 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 Catcher (exposto em `message_ids`) | | **InstanceID** | UUID v4 | `84c2e480-...` | Qual instância WhatsApp (número conectado) | | **SenderJID** | Phone JID | `554196332719@s.whatsapp.net` | Quem enviou a mensagem | ### Normalizacao automática A API normaliza automaticamente os identificadores: 1. **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). 2. **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`. O campo `phone` sempre traz o número puro (sem sufixo) e o `message_ids` sempre traz o UUID Catcher da mensagem. ### 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 (`@s.whatsapp.net`) | | `sender_jid` | string | **Quem enviou.** Phone JID do remetente | | `message_type` | string | text, image, video, audio, document, sticker, location, contact, reaction | | `content` | string | Texto ou caption | | `status` | string | queued → sent → delivered → read / failed | | `whatsapp_id` | string | ID da mensagem no WhatsApp (chave de correlação para status updates) | | `source` | string | `"api"` (fila Catcher), `"external"` (enviado por outra origem no mesmo número) | | `media_id` | string | Referência a mídia no S3 | > **Não existe tabela `Chat`.** Conversas são derivadas agrupando mensagens por `remote_jid`. O endpoint `GET /v1/instances/{instanceId}/chats` faz isso automaticamente. ### Correlação: como agrupar eventos em conversas **Chave primaria da conversa:** `instance_id` + campo `chat` do evento (ou `remote_jid` no banco). Todos os eventos de mensagem incluem o campo `chat`, que corresponde exatamente ao `remote_jid` no banco: | Evento | Campo de conversa | Campo de mensagem (UUID Catcher) | |--------|------------------|----------------------------------| | `message.received` | `chat` | `message_ids` (array) | | `message.sent` | `chat` | `message_ids` (array) | | `message.delivered` | `chat` | `message_ids` (array) | | `message.read` | `chat` | `message_ids` (array) | | `message.played` | `chat` | `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. ### 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 Catcher): ``` message.sent (message_ids = ["550e8400-e29b-41d4-a716-..."]) ↓ message.delivered (message_ids = ["550e8400-e29b-41d4-a716-..."]) ↓ message.read (message_ids = ["550e8400-e29b-41d4-a716-..."]) ↓ 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 ``` #### message_ids — UUIDs do Catcher O campo `message_ids` em todos os eventos de mensagem contem um **array de UUIDs internos do Catcher** (v4, 36 chars cada). Este identificador e: - **Garantido único** — nunca reciclado, mesmo quando o WhatsApp reutiliza IDs - **Estável** — o mesmo UUID persiste em todos os eventos do ciclo de vida da mensagem (received → delivered → read → played) - **Aceito nos endpoints da API** — os endpoints que operam sobre uma mensagem (react, mark-read) aceitam tanto o UUID do Catcher quanto o ID do WhatsApp (hex) Para a maioria dos eventos (`message.received`, `message.sent`), 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. Para operações da API e integrações de negócio, continue usando `message_ids`. ### 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 | --- ## 22. 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.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); as conexões WhatsApp da empresa foram desconectadas. | | `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-..." # Vincular o número oficial informando as credenciais da WABA # (ou use o fluxo guiado Embedded Signup — ver seção 6) curl -X POST https://api.catcher.one/v1/instances/$INSTANCE/official/credentials \ -H "X-API-Key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "waba_id": "...", "phone_number_id": "...", "access_token": "" }' # Ativar a conexão curl -X POST https://api.catcher.one/v1/instances/$INSTANCE/connect \ -H "X-API-Key: $TOKEN" ``` ### 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" ```