Instâncias WhatsApp

6. Instâncias WhatsApp#

Uma instância representa uma sessão WhatsApp conectada.

POST/v1/instances#

Cria uma nova instância.

Auth: Owner, Admin

Request:

json
{
  "name": "Atendimento Principal"
}

Resposta 201:

json
{
  "id": "84c2e480-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "name": "Atendimento Principal",
  "status": "DISCONNECTED",
  "phone": "",
  "created_at": "2026-03-07T10:00:00Z",
  "updated_at": "2026-03-07T10:00:00Z"
}

GET/v1/instances#

Lista todas as instâncias da empresa.

Auth: Owner, Admin

Resposta 200:

json
[
  {
    "id": "84c2e480-...",
    "name": "Atendimento Principal",
    "status": "CONNECTED",
    "phone": "554137984905",
    "connected_at": "2026-03-07T10:05:00Z",
    "last_seen": "2026-03-07T12:00:00Z",
    "last_activity_at": "2026-03-07T12:00:00Z",
    "desired_state": "CONNECTED",
    "tier": "healthy",
    "profile_name": "Empresa LTDA",
    "profile_pic_url": "https://...",
    "ban_expiry": null,
    "ban_reason": "",
    "proxy": {
      "ip_address": "200.x.x.x",
      "city": "br-saopaulo",
      "isp": "Vivo",
      "assigned_at": "2026-03-07T10:00:00Z"
    },
    "created_at": "2026-03-07T10:00:00Z",
    "updated_at": "2026-03-07T12:00:00Z"
  }
]

Campos omitidos quando vazios: last_error aparece so quando a instância teve problema (limpo ao conectar); ban_expiry/ban_reason so após ban temporario; disconnected_at/offline_duration_seconds/offline_duration_human/offline_alert_level so quando offline; last_logged_out_401_at so após restrição 401; proxy so quando ha IP dedicado vinculado. tier e calculado em tempo de resposta: healthy, stale, degraded, offline, critical_offline, intentional, pending, banned. GET /v1/instances NÃO retorna uptime — esse campo aparece apenas em GET /v1/instances/{instanceId}.


GET/v1/instances/{instanceId}#

Retorna detalhes de uma instância.

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",
  "last_activity_at": "2026-03-07T12:00:00Z",
  "disconnected_at": null,
  "tier": "healthy",
  "offline_duration_seconds": 0,
  "offline_duration_human": "",
  "offline_alert_level": "",
  "ban_expiry": null,
  "ban_reason": "",
  "last_logged_out_401_at": null,
  "desired_state": "CONNECTED",
  "profile_name": "Empresa LTDA",
  "proxy": null,
  "created_at": "2026-03-07T10:00:00Z",
  "updated_at": "2026-03-07T12:00:00Z",
  "uptime": "1h 55m 0s"
}

profile_pic_url aparece apenas quando ha avatar em cache. uptime aparece apenas quando status=CONNECTED. Diferente da listagem, este objeto NÃO retorna last_error.

Erros:

  • 404 - Instância não encontrada

PATCH/v1/instances/{instanceId}#

Renomeia a instância (label interno Catcher). O nome não afeta o perfil WhatsApp; ele e usado apenas no Console e nas integrações que listam instâncias.

Auth: Owner, Admin

Request:

json
{
  "name": "Atendimento SP"
}
Campo Tipo Obrigatório Descricao
name string sim 3-80 caracteres após trim. Único por empresa.

Resposta 200: Objeto de instância com o novo name.

Erros:

  • 400 BAD_REQUEST - name fora do tamanho permitido (3-80)
  • 404 INSTANCE_NOT_FOUND - instância não pertence a empresa autenticada
  • 409 DUPLICATE_INSTANCE_NAME - já existe outra instância com esse nome na mesma empresa

Notas:

  • Renomear sempre o mesmo valor (sem alteração real) e idempotente: retorna 200 com a instância atual sem tocar o DB.
  • A operação não toca whatsmeow nem reinicia a sessão — e uma alteração puramente de label no DB do tenant.

GET/v1/instances/{instanceId}/settings#

Retorna configurações operacionais da instância.

Auth: Owner, Admin

Resposta 200:

json
{
  "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53",
  "humanize_enabled": true,
  "media_jitter_enabled": true,
  "anti_spam_guard_enabled": true,
  "anti_spam_temperature_enabled": true,
  "max_outbound_without_inbound": 2
}
Campo Tipo Descricao
instance_id string ID da instância
humanize_enabled bool Controla a humanizacao automática de mensagens de texto enviadas pela API. Default true.
media_jitter_enabled bool Controla jitter de bytes por envio de mídia outbound antes do upload para a Meta. Default true.
anti_spam_guard_enabled bool Ativa o guard de duas regras (conteúdo duplicado + falta de reciprocidade). Default true.
anti_spam_temperature_enabled bool Toggle da regra de temperatura por instância. Default true.
max_outbound_without_inbound int Quantos envios consecutivos sem resposta inbound do mesmo contato são permitidos antes do bloqueio (1-10). Default 2.

Erros:

  • 404 INSTANCE_NOT_FOUND - instância não pertence a empresa autenticada

PATCH/v1/instances/{instanceId}/settings#

Atualiza configurações operacionais da instância. Aceita PATCH parcial — qualquer combinação dos campos abaixo.

Auth: Owner, Admin

Request:

json
{
  "humanize_enabled": false,
  "media_jitter_enabled": true,
  "anti_spam_guard_enabled": true,
  "anti_spam_temperature_enabled": true,
  "max_outbound_without_inbound": 3
}
Campo Tipo Obrigatório Descricao
humanize_enabled bool não Ativa/desativa composing + delay aleatorio antes de SendText
media_jitter_enabled bool não Ativa/desativa jitter de bytes por envio de mídia outbound. Quando false, o worker envia os bytes canônicos lidos do R2.
anti_spam_guard_enabled bool não Ativa/desativa o guard anti-spam (regras 1+2 abaixo)
anti_spam_temperature_enabled bool não Toggle da regra de temperatura por instância (default true). Quando false, o check de temperatura e pulado enquanto hash-dedup + reciprocidade continuam ativos.
max_outbound_without_inbound int não 1-10. Threshold para a regra de reciprocidade

Ao menos um dos campos e obrigatório. Os campos omitidos preservam o valor atual.

Resposta 200: Mesmo payload do GET /v1/instances/{instanceId}/settings após a atualização.

Erros:

  • 400 MISSING_FIELD - corpo sem nenhum campo aceito (humanize_enabled, media_jitter_enabled, anti_spam_guard_enabled, anti_spam_temperature_enabled, max_outbound_without_inbound)
  • 400 BAD_REQUEST - max_outbound_without_inbound fora do range 1-10
  • 404 INSTANCE_NOT_FOUND - instância não pertence a empresa autenticada

Anti-spam guard#

Quando anti_spam_guard_enabled=true (default), todo envio outbound passa por duas regras antes de ser enfileirado:

  1. Conteúdo duplicado (rolling 24h, escopo (instance_id, remote_jid)):

    • Texto: hash do text (trim).
    • Mídia: hash de media_url|media_id + caption + file_name.
    • Localização: hash de (lat, lng, name, address).
    • Contato: hash de (contact_name, contact_phone).
    • Poll: hash de question + options.
    • Template: hash de body + footer.
    • Reaction/Forward/Delete/Edit ficam fora dessa regra (operam em mensagem existente).
  2. Reciprocidade (rolling 7d, escopo (instance_id, remote_jid)):

    • Cada envio outbound incrementa o contador.
    • Cada inbound *events.Message zera o contador via worker.
    • Bloqueia quando o contador ultrapassa max_outbound_without_inbound.

Bloqueio: retorna 409 CONFLICT com:

  • error_code: "BLOCKED_DUPLICATE_CONTENT" — payload inclui remote_jid, content_hash, first_sent_at.
  • error_code: "BLOCKED_NO_RECIPROCITY" — payload inclui remote_jid, count, threshold.

Bypass: envie o header X-Force-Send: true na requisição. O guard registra audit log com audit_action=anti_spam.force_send. Tenant admin/owner deve usar com critério.


POST/v1/instances/{instanceId}/connect#

Conecta a instância ao WhatsApp. Gera QR code para parear.

Auth: Owner, Admin

Request (opcional):

json
{
  "qr_retries": 3
}
Campo Tipo Obrigatório Descricao
qr_retries int não Tentativas de QR (1-3). Padrão: 1

Resposta 200: Objeto de instância no estado atual do DB. A operação e assincrona: a transicao CONNECTING -> QR_PENDING -> CONNECTED acontece depois no worker. O QR NÃO vem nesta resposta — busque-o via GET /qr ou o stream GET /qr/stream.


POST/v1/instances/{instanceId}/disconnect#

Desconecta a instância do WhatsApp (mantem a sessão para reconexao).

Auth: Owner, Admin

Resposta 200: Objeto de instância atualizado.


POST/v1/instances/{instanceId}/restart#

Reinicia a conexão da instância.

Auth: Owner, Admin

Resposta 200: Objeto de instância atualizado.


POST/v1/instances/{instanceId}/logout#

Faz logout do WhatsApp e apaga a sessão. A instância precisara parear novamente.

Auth: Owner, Admin

Resposta 200: Objeto de instância atualizado.


POST/v1/instances/{instanceId}/history/import#

Solicita importação manual de histórico antigo. A chamada e assincrona: o endpoint apenas pede ao WhatsApp uma página de histórico por conversa conhecida; as mensagens retornam depois em eventos history.sync e são gravadas na tabela messages, ficando disponíveis em GET /v1/instances/{instanceId}/chats e GET /v1/instances/{instanceId}/chats/{chatId}/messages.

Auth: Owner, Admin

Request (opcional):

json
{
  "chat_jid": "554137984905@s.whatsapp.net",
  "limit": 50
}
Campo Tipo Obrigatório Descricao
chat_jid string não Quando informado, solicita histórico apenas desse chat. Vazio = uma página para cada chat com mensagem ancora já salva
limit int não Mensagens por chat. Padrão 50, máximo 100

Resposta 200:

json
{
  "instance_id": "84c2e480-...",
  "status": "requested",
  "limit": 50,
  "requested_chats": 3,
  "requested_messages": 150
}
Campo Tipo Descricao
status string requested quando ao menos um pedido foi enviado ao WhatsApp; skipped quando não havia ancora de mensagem
chat_jid string Echo do chat alvo quando a request especificou chat_jid
requested_chats int Quantos chats receberam pedido ON_DEMAND
requested_messages int Máximo teorico solicitado (requested_chats * limit)
skipped_chats int Chats que tinham ancora mas o pedido ao WhatsApp falhou
message string Motivo quando status=skipped

Notas:

  • A importação manual depende de uma mensagem ancora já existente no histórico local. Para conexões novas, o history.sync automático do WhatsApp agora também e persistido e normalmente cria essas ancoras.
  • Mensagens históricas são salvas com source="phone" para inbound e source="external" para outbound vindo do celular/WhatsApp Web. Elas não disparam message.received/message.sent como se fossem tráfego novo ao vivo.
  • O retorno do WhatsApp continua sendo resumido por history.sync; leia as mensagens importadas pelos endpoints de chat/mensagens.

Erros:

  • 400 - JSON inválido
  • 404 - Instância não encontrada
  • 400 - Instância não conectada ou falha ao solicitar histórico ao worker

DELETE/v1/instances/{instanceId}#

Remove a instância permanentemente.

Auth: Owner, Admin

Resposta 204: Sem corpo.


GET/v1/instances/{instanceId}/usage#

Retorna contadores de mensagens da instância nas últimas 24h.

Auth: Owner, Admin, Agent

Resposta 200:

json
{
  "messages_today": 128,
  "messages_sent_today": 80,
  "messages_received_today": 48,
  "delivered_today": 76,
  "failed_today": 4
}
Campo Tipo Descricao
messages_today int Total enviadas + recebidas nas últimas 24h
messages_sent_today int Mensagens outbound nas últimas 24h
messages_received_today int Mensagens inbound nas últimas 24h
delivered_today int Outbound que chegaram ao WhatsApp (status sent/delivered/read/played)
failed_today int Outbound com falha (status failed ou failed_at preenchido)

Erros:

  • 400 - instanceId ausente
  • 404/500 - instância/tenant DB indisponível

GET/v1/instances/{instanceId}/throughput#

Retorna 24 buckets horarios de atividade outbound nas últimas 24h (alinhados a hora UTC, sempre em ordem cronologica; horas sem atividade retornam zeros).

Auth: Owner, Admin, Agent

Resposta 200:

json
{
  "buckets": [
    { "hour": "2026-05-16T13:00:00Z", "sent": 12, "delivered": 11, "failed": 1 }
  ]
}
Campo Tipo Descricao
hour string Início do bucket (RFC3339, UTC)
sent int Mensagens outbound criadas nessa hora
delivered int Outbound que chegaram ao WhatsApp (status além de queued, sem falha)
failed int Outbound com falha

Erros:

  • 400 - instanceId ausente
  • 500 - tenant DB indisponível / falha ao carregar throughput

GET/v1/instances/{instanceId}/connection-timeline#

Retorna uma serie cronologica de segmentos de conexão/desconexao das últimas 24h (default) ou 7 dias.

Auth: Owner, Admin, Agent

Query Parameters:

Parametro Tipo Descricao
window string 7d para janela de 7 dias. Omitido = 24h

Resposta 200:

json
{
  "window_start": "2026-05-16T13:00:00Z",
  "window_end": "2026-05-17T13:00:00Z",
  "segments": [
    { "start": "2026-05-16T13:00:00Z", "end": "2026-05-17T13:00:00Z", "status": "connected" }
  ]
}
Campo Tipo Descricao
segments[].status string connected, disconnected ou unknown

Erros:

  • 400 - instanceId ausente
  • 500 - tenant DB indisponível / falha ao carregar timeline

GET/v1/instances/{instanceId}/latency#

Retorna 24 buckets horarios de latencia de envio -> confirmação (p50, p95) para mensagens outbound das últimas 24h.

Auth: Owner, Admin, Agent

Resposta 200:

json
{
  "buckets": [
    { "hour": "2026-05-16T13:00:00Z", "count": 14, "p50_ms": 820, "p95_ms": 2400 }
  ]
}
Campo Tipo Descricao
hour string Início do bucket (RFC3339, UTC)
count int Mensagens com receipt nesse bucket
p50_ms int Latencia mediana em ms (sent_at -> primeiro receipt: delivered/read/played)
p95_ms int Latencia p95 em ms

Erros:

  • 400 - instanceId ausente
  • 500 - tenant DB indisponível / falha ao carregar latencia

GET/v1/instances/{instanceId}/logs#

Retorna linhas de log filtradas e escopadas a instância (e a empresa autenticada). Util para diagnostico — owners/admins veem porque suas mensagens não persistem sem precisar de acesso superadmin.

Auth: Owner, Admin, Agent

Query Parameters:

Parametro Tipo Descricao
limit int Linhas retornadas (default 200, máximo 1000)
level string Filtro de nivel (error, warn, info...)
contains string Filtro de substring

Resposta 200:

json
{
  "instance_id": "84c2e480-...",
  "company_id": 7,
  "total": 1240,
  "showing": 200,
  "limit": 200,
  "lines": ["{\"level\":\"info\",...}"]
}

Erros:

  • 400 - instanceId ausente
  • 404 - arquivo de log não encontrado
  • 503 - diretorio de log não configurado

GET/v1/instances/{instanceId}/health#

Retorna o snapshot de saúde da instância — score, contadores cumulativos por tipo de evento, meta_paired_at e os eventos de proxy mais recentes escopados a essa instância.

Auth: Owner, Admin, Agent

Resposta 200:

json
{
  "instance_id": "84c2e480-...",
  "name": "Atendimento Principal",
  "status": "CONNECTED",
  "desired_state": "CONNECTED",
  "phone": "554137984905",
  "health_score": 92,
  "health_score_updated_at": "2026-05-17T12:00:00Z",
  "meta_paired_at": "2026-05-10T09:00:00Z",
  "last_activity_at": "2026-05-17T12:30:00Z",
  "last_seen": "2026-05-17T12:30:05Z",
  "connected_at": "2026-05-10T09:01:00Z",
  "disconnected_at": null,
  "proxy_ip_id": 42,
  "totals": {
    "connects": 5,
    "disconnects": 4,
    "qr_cycles": 2,
    "qr_timeouts": 0,
    "connect_failures": 1,
    "cooldowns_hit": 0,
    "bans": 0
  },
  "proxy": {
    "id": 42,
    "ip_address": "200.x.x.x",
    "city": "br-saopaulo",
    "status": "assigned",
    "health_score": 100,
    "last_event_at": "2026-05-17T12:00:00Z"
  },
  "recent_events": []
}
Campo Tipo Descricao
health_score int Score de saúde acumulado da instância
meta_paired_at string/null Quando a instância foi pareada ao Meta
totals object Contadores cumulativos por tipo de evento
proxy object Presente quando a instância tem proxy bound
recent_events array Eventos de proxy escopados a essa instância (até 50)

Erros:

  • 400 INVALID_ID - instanceId ausente
  • 403 FORBIDDEN - contexto tenant ausente
  • 404 INSTANCE_NOT_FOUND - instância não encontrada

GET/v1/instances/{instanceId}/proxy-reputation#

Retorna o status de reputacao (sanitizado) do IP de proxy atualmente vinculado a instância. O score numerico, nomes de provedores e dados de ASN não são expostos — esse e um selo amigavel para o cliente.

Auth: Owner, Admin, Agent

Resposta 200:

json
{
  "status": "ok",
  "country": "BR",
  "city": "br-saopaulo",
  "isp_label": "Internet residencial",
  "checks_passed": 4,
  "checks_total": 4,
  "checked_at": "2026-05-17T10:00:00Z",
  "next_check_at": "2026-05-24T10:00:00Z"
}
Campo Tipo Descricao
status string ok, watch, bad, inconclusive ou unknown
isp_label string Internet residencial, Conexao corporativa, Em rotacao ou Verificando
checks_passed / checks_total int Contagens genericas de verificacoes

Instâncias legadas sem proxy retornam {"status":"unknown"} para o frontend esconder o selo.

Erros:

  • 400 BAD_REQUEST - instanceId ausente
  • 404 INSTANCE_NOT_FOUND - instância não encontrada
  • 503 SERVICE_UNAVAILABLE - tenant DB indisponível

GET/v1/instances/{instanceId}/qr#

Retorna o QR code atual para parear a instância.

Auth: Owner, Admin

Resposta 200:

json
{
  "qr_code": "2@abc123...",
  "qr_base64": "iVBORw0KGgo...",
  "instance_id": "84c2e480-..."
}

O campo qr_base64 contem a imagem PNG do QR em base64. Se a instância não tiver QR pendente, retorna 400.


GET/v1/instances/{instanceId}/qr/stream#

Stream SSE de QR codes. O servidor envia QR codes conforme são gerados pelo WhatsApp (~20s cada, até 6 por sessão). Quando o usuário escaneia, envia success. Se todos os QRs expiram, envia timeout.

Auth: Owner, Admin

Headers recomendados para proxies reversos:

  • X-Accel-Buffering: no (já incluído na resposta)
  • Desabilitar compressao (no-gzip) para evitar buffering do SSE

Resposta: Server-Sent Events

text
event: connected
data: {"instance_id": "84c2e480-..."}

event: code
data: {"event": "code", "qr": "2@abc123...", "qr_base64": "iVBORw0KGgo...", "mime_type": "image/png"}

event: success
data: {"event": "success"}

event: timeout
data: {"event": "timeout"}

event: error
data: {"event": "error", "message": "descricao do erro"}
Evento Descricao
connected Stream iniciado (enviado imediatamente)
code Novo QR code disponível. qr = raw string, qr_base64 = imagem PNG base64
success Usuário escaneou o QR, instância pareada
timeout Todos os QR codes expiraram sem leitura
error Erro ao gerar QR

Nota: O campo qr_base64 contem a imagem PNG do QR pronta para exibir: <img src="data:image/png;base64,${qr_base64}">. O campo qr contem a string raw para gerar o QR localmente.

Proxy reverso: Se usar Apache/Nginx como proxy, desabilite compressao e buffering para endpoints SSE (/qr/stream e /events). Sem isso, o browser recebe ERR_INCOMPLETE_CHUNKED_ENCODING.


POST/v1/instances/{instanceId}/pairing-code#

Gera um código de pareamento (alternativa ao QR code, para parear pelo telefone).

Auth: Owner, Admin

Request:

json
{
  "phone": "554137984905"
}

Resposta 200:

json
{
  "instance_id": "84c2e480-...",
  "pairing_code": "SG3M-82AE"
}

Como usar: No WhatsApp do celular, va em Configurações > Aparelhos Conectados > Conectar Aparelho > Conectar com número de telefone. Digite o código recebido.


6.1 Boas Práticas — Fluxo de Conexão (QR dinâmico e Pairing Code)#

Está seção descreve como montar uma tela de conexão rápida, resiliente e com UX parecida com a do Catcher Console. Os usuários que apenas fazem polling em GET /qr ou abrem o SSE sem uma estrategia ficam com telas lentas, QR "parado" e retry manual irritante — todos esses problemas são resolvidos com o padrão abaixo.

Maquina de estados da instância#

text
CREATED ──POST /connect──► CONNECTING ──► QR_PENDING ──(scan)──► CONNECTED
                                │                │
                                ▼                ├─(timeout 6 QRs)─► TIMED_OUT
                        DISCONNECTED             │
                                                 └─(erro)──────────► DISCONNECTED
Estado Significado Ação do cliente
CREATED Instância criada, ainda não tentou conectar POST /connect para iniciar
CONNECTING Abrindo websocket com WhatsApp, gerando primeiro QR Abrir /qr/stream e esperar evento code
QR_PENDING Pelo menos um QR já disponível Exibir QR e aguardar success/timeout
CONNECTED Pareado e recebendo mensagens Fechar modal, liberar a tela
TIMED_OUT 6 QRs expiraram sem leitura (~2min total) POST /restart e reabrir stream, ou pedir ao usuário para tentar de novo
DISCONNECTED Desconectada (manual ou erro) POST /connect para reativar
BANNED Banida pelo WhatsApp Não reconecte automaticamente — veja seção 16, evento instance.banned

O WhatsApp gera um novo QR a cada ~20 segundos e aceita até 6 QRs por sessão de pareamento (totalizando ~2 minutos). Esses QRs chegam automaticamente no /qr/stream via evento codenão existe endpoint "próximo QR". Se você perder esse stream, precisa reiniciar (POST /restart) para reabrir a sessão.

Fluxo recomendado (5 passos)#

text
1. POST /v1/instances/{id}/connect                  ◄── dispara QR generation
2. GET  /v1/instances/{id}/qr              (snapshot) ◄── tela nao fica branca
3. GET  /v1/instances/{id}/qr/stream       (SSE)     ◄── recebe novos QRs + success/timeout
4. GET  /v1/instances/{id}/events?events=connection.update  (SSE paralelo)  ◄── rede de seguranca
5. Ao receber "success" ou CONNECTED: fechar streams, mostrar sucesso

A combinação /qr/stream + /events em paralelo e a diferença entre "funciona na maioria das vezes" e "funciona sempre". Se um proxy reverso engolir o evento success do /qr/stream, a transicao CONNECTING → CONNECTED em connection.update ainda chega pelo /events e você fecha o modal corretamente.

Contrato do /qr/stream#

O servidor envia eventos nomeados SSE conforme a seção GET /v1/instances/{instanceId}/qr/stream. Resumindo:

Evento Quando ocorre Payload relevante O que fazer
connected Imediatamente ao abrir { "instance_id": "..." } Confirmar que o stream abriu
code Novo QR disponível (0s, ~20s, ~40s...) { "qr_base64": "...", "qr": "2@...", "mime_type": "image/png" } Renderizar <img src="data:image/png;base64,${qr_base64}">
success Usuário escaneou o QR { "event": "success" } Fechar modal, emitir toast de sucesso
timeout 6 QRs expiraram sem scan { "event": "timeout" } Silent retry ou prompt de "tente de novo"
error Falha na sessão whatsmeow { "event": "error" } Silent retry ou mostrar erro

Idle timeout: se o stream ficar mais de ~45s sem nenhum evento (nem heartbeat), trate como conexão morta e reabra. Redes móveis e proxies derrubam conexões idle silenciosamente.

Limite de subscribers: cada instância aceita até 20 conexões SSE/WebSocket simultaneas (somando /qr/stream + /events + clientes externos). Se você abrir streams em varias abas ou não fizer cleanup, atinge o limite e recebe 429 Too Many Requests. Sempre use AbortController para cancelar.

Padrão 1 — Cliente Browser (JS/TS)#

Por que não usar EventSource nativo: o construtor EventSource(url) não aceita headers customizados, logo você não consegue mandar Authorization: Bearer <token>. A solução usada pelo Catcher Console e usar fetch() com ReadableStream, o que permite headers e cancelamento via AbortController.

javascript
/**
 * Abre o stream SSE de QR code para uma instancia.
 * Retorna uma funcao `close()` para abortar o stream limpo.
 */
function openQRStream(instanceId, token, handlers) {
  const controller = new AbortController();
  const url = `https://api.catcher.one/v1/instances/${instanceId}/qr/stream`;

  fetch(url, {
    method: 'GET',
    signal: controller.signal,
    cache: 'no-store',
    headers: {
      Accept: 'text/event-stream',
      Authorization: `Bearer ${token}`,
    },
  })
    .then(async (response) => {
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      if (!response.body) throw new Error('stream body unavailable');

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });

        // Eventos SSE sao delimitados por \n\n
        let boundary;
        while ((boundary = buffer.indexOf('\n\n')) !== -1) {
          const raw = buffer.slice(0, boundary);
          buffer = buffer.slice(boundary + 2);
          if (!raw.trim()) continue;

          let eventType = 'message';
          let data = '';
          for (const line of raw.split('\n')) {
            if (line.startsWith(':')) continue;         // heartbeat, ignora
            if (line.startsWith('event:')) eventType = line.slice(6).trim();
            else if (line.startsWith('data:'))
              data = data ? `${data}\n${line.slice(5).trim()}` : line.slice(5).trim();
          }

          try {
            handlers.onEvent?.(eventType, data ? JSON.parse(data) : {});
          } catch { /* payload malformado, ignora */ }
        }
      }
      handlers.onClose?.();
    })
    .catch((err) => {
      if (err.name === 'AbortError') return;
      handlers.onError?.(err);
    });

  return () => controller.abort();
}

// Uso:
const close = openQRStream(instanceId, token, {
  onEvent: (type, data) => {
    if (type === 'code')    setQR(data.qr_base64);
    if (type === 'success') { setConnected(true); close(); }
    if (type === 'timeout') silentRetry();
  },
  onError: () => silentRetry(),
});

Snapshot antes do stream: para evitar o flash de "Gerando QR Code..." quando já existe um QR pendente, chame GET /qr antes de abrir o stream e renderize o QR imediatamente. O stream entao atualizara o QR a cada 20s.

javascript
// 1. Snapshot sincrono — tela mostra QR na hora
const snap = await fetch(`${api}/instances/${id}/qr`, { headers: auth }).then(r => r.ok ? r.json() : null);
if (snap?.qr_base64) setQR(snap.qr_base64);
// 2. Em seguida o stream mantem o QR atualizado
const close = openQRStream(id, token, handlers);

Alternativa mais simples: se você não quiser escrever o parser manual, use a biblioteca @microsoft/fetch-event-source. Ela expoe uma API fetchEventSource() identica ao EventSource nativo mas com suporte a headers, retry automático e AbortController.

Padrão 2 — Cliente Backend (Node/Go/Python)#

No backend não ha restrição de headers, entao qualquer cliente SSE funciona. Curl e um bom teste rápido:

bash
curl -N \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: text/event-stream" \
  "https://api.catcher.one/v1/instances/$INSTANCE_ID/qr/stream"

Exemplo em Go com bufio.Scanner:

go
req, _ := http.NewRequest("GET", baseURL+"/v1/instances/"+instanceID+"/qr/stream", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "text/event-stream")

resp, err := http.DefaultClient.Do(req)
if err != nil { /* ... */ }
defer resp.Body.Close()

scanner := bufio.NewScanner(resp.Body)
var eventType, data string
for scanner.Scan() {
    line := scanner.Text()
    switch {
    case strings.HasPrefix(line, "event:"):
        eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
    case strings.HasPrefix(line, "data:"):
        data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
    case line == "":
        // linha em branco = fim do evento
        handleQREvent(eventType, data)
        eventType, data = "", ""
    }
}

Dupla subscricao — /qr/stream + /events em paralelo#

Como rede de seguranca, assine connection.update em paralelo pelo endpoint de eventos da instância. Isso garante que você fecha o modal mesmo se o success do /qr/stream for perdido por qualquer motivo (buffering de proxy, timing de fechamento do stream, etc.).

javascript
// Stream de QR (atualiza imagem)
const closeQR = openQRStream(instanceId, token, {
  onEvent: (type, data) => {
    if (type === 'code')    setQR(data.qr_base64);
    if (type === 'success') markConnected();
    if (type === 'timeout') silentRetry();
  },
});

// Stream de eventos da instancia (rede de seguranca)
const closeEvents = openEventStream(instanceId, token, ['connection.update'], (event) => {
  if (event.type === 'connection.update' && event.data?.status === 'CONNECTED') {
    markConnected();
  }
});

function markConnected() {
  closeQR();
  closeEvents();
  showSuccess('Instancia conectada!');
}

Se você já tem uma conexão /events global no seu app (por exemplo, ouvindo message.received o tempo todo), apenas adicione um listener para connection.update durante a tela de conexão — não abra uma segunda conexão para a mesma instância, você gastara uma das 20 slots disponíveis.

Auto-retry silencioso (UX sem friction)#

Usuários abandonam a tela de conexão quando veem "QR expirou, clique para tentar novamente". A tela do Console aplica retry invisível: ao receber timeout ou error, dispara POST /restart e reabre o /qr/stream, mantendo o QR atual visível até o próximo evento code.

javascript
const MAX_RETRIES = 10;
let retries = 0;

async function silentRetry() {
  if (retries >= MAX_RETRIES) { showTimeoutUI(); return; }
  retries += 1;

  // pequeno debounce antes de reiniciar
  await new Promise(r => setTimeout(r, 1500));

  // reinicia a sessao do lado do servidor
  await fetch(`${api}/instances/${id}/restart`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
  });

  // reabre o stream — o QR atual continua visivel ate chegar um novo `code`
  closeQR = openQRStream(id, token, handlers);
}

Não apague o QR atual durante o retry — deixe o último QR válido na tela. Se o novo code chegar antes do usuário tentar escanear o anterior, o anterior ainda funciona por alguns segundos. Trocar a imagem so quando chegar o evento code evita flicker.

Pairing Code como alternativa ao QR#

O pairing code e util quando o usuário não tem uma camera funcionando (desktop sem webcam, usuário com dificuldade visual etc.). Tres regras importantes:

  1. Requer POST /connect ANTES. A chamada POST /pairing-code so funciona se a instância estiver em CONNECTING ou QR_PENDING — caso contrario retorna 400 instance not connected, call connect first. Na prática, execute o mesmo fluxo do QR e apenas troque a interface.
  2. Código expira em ~60 segundos. Use um timer no cliente, mostre a contagem regressiva e permita gerar um novo código.
  3. Sucesso chega pelo mesmo /events?events=connection.update — não ha stream dedicado para pairing. Mantenha a subscricao de connection.update ativa e feche a UI quando vier status: CONNECTED.
javascript
// 1. Garante que a sessao esta CONNECTING
await fetch(`${api}/instances/${id}/connect`, { method: 'POST', headers: auth });

// 2. Gera o codigo (phone inclui DDI, ex: "554137984905")
const res = await fetch(`${api}/instances/${id}/pairing-code`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', ...auth },
  body: JSON.stringify({ phone }),
});
const { pairing_code } = await res.json();  // ex: "SG3M82AE"
setCode(formatPairingCode(pairing_code));   // "SG3M-82AE"

// 3. Timer de expiracao
setTimeout(() => {
  setCode(null);
  toast.info('Codigo expirado, gere um novo.');
}, 60_000);

// 4. Sucesso vem via connection.update
//    (ja esta assinado pelo stream de /events da rede de seguranca)

Checklist — como NÃO fazer#

Anti-padrão Consequência O certo
Usar new EventSource(url) sem token na URL 401 silencioso, stream nunca abre fetch() + ReadableStream ou @microsoft/fetch-event-source
Token via query string (?token=) Rejeitado (401); query auth foi removida Header Authorization: Bearer <token>
Polling de GET /qr a cada 1s Spam de 60 req/min, QR desatualizado SSE /qr/stream (push-based, atualiza sozinho)
Não chamar POST /connect antes GET /qr devolve 400, stream so envia connected e fica parado Sempre POST /connect primeiro
Não chamar GET /qr como snapshot Tela branca por 2-5s na primeira abertura Snapshot sincrono antes do stream
Abrir stream, não cancelar no unmount Vaza subscribers, esgota o limite de 20 e vira 429 AbortController.abort() ao fechar
Abrir 2+ streams para a mesma instância Consome slots a toa Reaproveite um único stream via listener pub/sub
Apagar o QR ao receber timeout Flicker visual, UX quebrada Manter o QR visível até o próximo code
Não renovar o access token antes de reabrir 401 durante reconexao longa POST /auth/refresh antes de tentar reconectar
Depender apenas do success do /qr/stream Perde o sucesso se o proxy buffer engolir o evento Dupla subscricao com /events?events=connection.update
Reconectar sem backoff Loop de erros em caso de falha persistente Backoff exponencial (2s → 4s → 8s…), cap em 30s
Ignorar o evento instance.banned Retry infinito em instância banida Parar reconexao ao receber banned — veja seção 16

Limites operacionais#

Limite Valor Observação
QRs por sessão de pareamento 6 ~20s cada, ~2min total antes de timeout
Validade do pairing code ~60s Gere novo após expirar
Subscribers por instância (SSE+WS somados) 20 Reutilize conexões, sempre cancele no unmount
Buffer de replay por instância 100 eventos Use Last-Event-ID ou ?last_event_id= para retomar após queda curta
Heartbeat do SSE ~15s Comentário : heartbeat — ignore no parser
Idle timeout recomendado do cliente 45s Se nada chegar nesse tempo, reconecte
Retry silencioso recomendado até 10 vezes Depois, mostrar prompt manual

Referência rápida dos endpoints#

Passo Endpoint Método Finalidade
1. Iniciar /v1/instances/{id}/connect POST Dispara sessão whatsmeow e geração de QR
2. Snapshot /v1/instances/{id}/qr GET Último QR pendente (tela imediata)
3. Stream /v1/instances/{id}/qr/stream GET (SSE) Novos QRs + success/timeout
4. Rede de seguranca /v1/instances/{id}/events?events=connection.update GET (SSE) Status CONNECTED autoritativo
5a. Retry /v1/instances/{id}/restart POST Reabrir sessão após timeout/erro
5b. Pairing /v1/instances/{id}/pairing-code POST Alternativa ao QR (exige passo 1)

Use está receita como ponto de partida e adapte ao seu stack. A implementação completa de referência está em frontend/src/views/instances/components/ConnectionModal.vue no próprio repositorio do Catcher Console.