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",
    "profile_name": "Empresa LTDA",
    "profile_pic_url": "https://...",
    "last_error": "",
    "ban_expiry": null,
    "ban_reason": "",
    "created_at": "2026-03-07T10:00:00Z",
    "updated_at": "2026-03-07T12:00:00Z",
    "uptime": "1h 55m 0s"
  }
]

O campo last_error aparece quando a instância teve um problema (ex: conflito de número). E limpo automaticamente quando a instância conecta com sucesso. Os campos ban_expiry e ban_reason aparecem quando a instância recebeu ban temporario do WhatsApp.


GET/v1/instances/{instanceId}#

Retorna detalhes de uma instância.

Auth: Owner, Admin

Resposta 200: Objeto de instância (mesmo formato da listagem).

Exemplo com erro de conflito:

json
{
  "id": "aaa-bbb-...",
  "name": "Duplicada",
  "status": "DISCONNECTED",
  "last_error": "phone 554192464230 already connected on instance 84c2e480-...",
  "created_at": "2026-03-08T01:00:00Z",
  "updated_at": "2026-03-08T01:00:05Z"
}

Erros:

  • 404 - Instância não encontrada

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

Retorna os toggles tenant-controllables de stealth da instância.

Auth: Owner, Admin

Resposta 200:

json
{
  "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53",
  "humanize_default": {
    "typing_ms": 500
  },
  "hygiene_skip": false,
  "require_inbound_first": true
}

Notas:

  • require_inbound_first=true faz o worker bloquear Send* para chats sem inbound previo na janela de 30 dias, retornando ERR_CHAT_NOT_INITIATED antes de qualquer warmup gate.
  • O cache de elegibilidade do gate fica em Redis por 5 minutos por instance + chat; o fallback consulta a tenant DB.

Erros:

  • 404 - Instância não encontrada

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

Atualiza os toggles tenant-controllables de stealth da instância.

Auth: Owner, Admin

Request (campos opcionais, envie pelo menos um):

json
{
  "humanize_default": {
    "typing_ms": 500
  },
  "hygiene_skip": false,
  "require_inbound_first": true
}
Campo Tipo Obrigatório Descricao
humanize_default object/null não Default de humanizacao persistido em tenant_instances.humanize_default
hygiene_skip bool não Opt-out da higiene automática
require_inbound_first bool não Ativa/desativa o gate inbound-first por instância

Resposta 200: Mesmo payload do GET /v1/instances/{instanceId}/stealth.

Erros:

  • 400 - JSON inválido ou corpo sem nenhum campo atualizavel
  • 404 - Instância não encontrada

GET/v1/stealth/wizard-defaults#

Retorna o perfil padrão do onboarding Stealth Mode salvo para a empresa autenticada.

Auth: Owner, Admin

Resposta 200:

json
{
  "profile": "safe",
  "configured": false
}

Notas:

  • configured=false significa que a empresa ainda não salvou um perfil explicito; o frontend deve tratar safe como recomendacao padrão.
  • Valores válidos de profile: test, safe, max.

Erros:

  • 403 - Contexto tenant ausente (ex.: sessão superadmin)

PATCH/v1/stealth/wizard-defaults#

Salva o perfil padrão do onboarding Stealth Mode para a empresa autenticada.

Auth: Owner, Admin

Request:

json
{
  "profile": "max"
}
Campo Tipo Obrigatório Descricao
profile string sim Um de test, safe, max

Resposta 200:

json
{
  "profile": "max",
  "configured": true
}

Erros:

  • 400 - JSON inválido ou profile fora de test|safe|max
  • 403 - Contexto tenant ausente (ex.: sessão superadmin)

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

Retorna a timeline de auditoria Stealth Mode da instância.

Auth: Owner, Admin

Query Parameters:

Parametro Tipo Descricao
type string Filtra por event_type exato (fingerprint.rotated, warmup.phase_changed, etc.)
from string Início do intervalo em RFC3339 ou YYYY-MM-DD
to string Fim do intervalo em RFC3339 ou YYYY-MM-DD
limit int Máximo de linhas retornadas (default 100, max 500)

Resposta 200:

json
{
  "events": [
    {
      "id": 42,
      "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53",
      "event_type": "fingerprint.rotated",
      "event_data": {
        "profile_id": "fp_01j5n3m2m9j6n6k0a4f3s8r2q1"
      },
      "occurred_at": "2026-04-21T13:00:00Z",
      "created_at": "2026-04-21T13:00:00Z"
    }
  ]
}

Erros:

  • 400 - limit inválido ou datas fora de RFC3339 / YYYY-MM-DD
  • 404 - Instância não encontrada

GET/v1/instances/{instanceId}/audit/export?format=csv|pdf#

Gera um snapshot point-in-time da timeline Stealth Mode da instância e devolve o arquivo como attachment.

Auth: Owner, Admin

Query Parameters:

Parametro Tipo Descricao
format string Obrigatório. csv ou pdf
type string Mesmo filtro do GET /audit
from string Mesmo filtro do GET /audit
to string Mesmo filtro do GET /audit

Resposta 200:

  • format=csv -> Content-Type: text/csv; charset=utf-8
  • format=pdf -> Content-Type: application/pdf
  • Ambos retornam Content-Disposition: attachment; filename="instance-audit-<instancia>-<timestamp>.<ext>"

Notas:

  • O export usa placeholder neutro de branding (Instance Audit Export) para não expor a marca BiaZap em artefatos compartilhados pelo tenant.
  • O endpoint e o caminho recomendado para retenção própria do cliente além da janela online de STEALTH_AUDIT_RETENTION.

Erros:

  • 400 - format fora de csv|pdf ou datas inválidas
  • 404 - Instância não encontrada

GET/v1/instances/{instanceId}/stealth/verify#

Executa uma verificação fresca do transporte uTLS da instância no worker. O worker abre um novo request HTTPS pela mesma trilha de sessão (TCP -> CONNECT -> uTLS -> HTTP/1.1) contra o servidor interno de verificação, captura o ClientHello, calcula o hash JA3 e devolve o resultado em cache por 15 minutos por instância.

Auth: Owner, Admin

Resposta 200:

json
{
  "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53",
  "profile_id": "fp_01j5n3m2m9j6n6k0a4f3s8r2q1",
  "utls_spec": "HelloChrome_120",
  "ja3_hash": "473f0e7c0b6a0f7b049072f4e683068b",
  "verified_at": "2026-04-21T12:00:00Z",
  "cached": false
}

Notas:

  • cached=true indica que o worker serviu o último resultado válido (TTL de 15 minutos) sem repetir o handshake.
  • Quando STEALTH_UTLS_ENABLED=false, o endpoint responde erro porque não existe transporte uTLS ativo para verificar.
  • Quando a instância não tem proxy dedicado e STEALTH_FAIL_CLOSED=true, o worker recusa a verificação com erro de binding ausente.

Erros:

  • 404 - Instância não encontrada
  • 502 - Falha ao consultar o worker/servidor de verificação

PATCH/v1/instances/{instanceId}#

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

Auth: Owner, Admin

Request:

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

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

Erros:

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

Notas:

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

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

Retorna configurações operacionais da instância.

Auth: Owner, Admin

Resposta 200:

json
{
  "instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53",
  "humanize_enabled": true,
  "media_jitter_enabled": true,
  "anti_spam_guard_enabled": true,
  "anti_spam_temperature_enabled": true,
  "max_outbound_without_inbound": 2
}
Campo Tipo Descricao
instance_id string ID da instância
humanize_enabled bool Controla a humanizacao automática de mensagens de texto enviadas pela API. Default true.
media_jitter_enabled bool Controla jitter de bytes por envio de mídia outbound antes do upload para a Meta. Default true.
anti_spam_guard_enabled bool Ativa o guard anti-spam (conteúdo duplicado em 24h + limite consecutivo sem resposta). Default true.
anti_spam_temperature_enabled bool Toggle da curva de temperatura por contato. Quando true (default), substitui o limite fixo max_outbound_without_inbound por uma curva derivada do score por (instance, remote_jid): cold 2 / warm 3 / engaged 4 / hot 5 / very_hot 7.
max_outbound_without_inbound int Limite fixo de envios consecutivos sem resposta (1-10, default 2). Aplicado apenas quando anti_spam_temperature_enabled=false. Ignorado quando a temperatura está ativa.

Erros:

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

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

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

Auth: Owner, Admin

Request:

json
{
  "humanize_enabled": false,
  "media_jitter_enabled": true,
  "anti_spam_guard_enabled": true,
  "anti_spam_temperature_enabled": true,
  "max_outbound_without_inbound": 3
}
Campo Tipo Obrigatório Descricao
humanize_enabled bool não Ativa/desativa composing + delay aleatorio antes de SendText
media_jitter_enabled bool não Ativa/desativa jitter de bytes por envio de mídia outbound. Quando false, o worker envia os bytes canônicos lidos do R2.
anti_spam_guard_enabled bool não Ativa/desativa o guard anti-spam (regras abaixo). Quando false, todas as regras são puladas.
anti_spam_temperature_enabled bool não Quando true (default), a curva de temperatura por contato substitui max_outbound_without_inbound (curva: cold 2 / warm 3 / engaged 4 / hot 5 / very_hot 7). Quando false, vale o limite fixo. O hash-dedup de 24h continua ativo em ambos os casos.
max_outbound_without_inbound int não 1-10. Limite fixo aplicado apenas quando anti_spam_temperature_enabled=false. Ignorado quando a temperatura está ativa.

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

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

Erros:

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

Anti-spam guard#

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

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

    • Texto: hash do text (trim).
    • Mídia: hash de media_url|media_id + caption + file_name.
    • Localização: hash de (lat, lng, name, address).
    • Contato: hash de (contact_name, contact_phone).
    • Poll: hash de question + options.
    • Template: hash de body + footer.
    • Reaction/Forward/Delete/Edit ficam fora dessa regra (operam em mensagem existente).
    • Reset por inbound: quando o contato responde (qualquer *events.Message com IsFromMe=false), TODOS os hashes acumulados nas últimas 24h pra esse (instance_id, remote_jid) são descartados. Permite que respostas curtas naturais ("sim", "ok", "obrigado") se repitam ao longo do dia sem disparar bloqueio. O padrão anti-blast original (mesmo template enviado N vezes em sequência sem nenhuma resposta) continua sendo bloqueado normalmente porque, por definição, não há inbound nele.
  2. Reciprocidade — limite consecutivo sem resposta inbound. A política depende de anti_spam_temperature_enabled:

    • Quando anti_spam_temperature_enabled=true (default) → a curva de temperatura por contato substitui o limite fixo. max_outbound_without_inbound é ignorado. O limite efetivo passa a ser derivado do score por (instance_id, remote_jid): cold 2 · warm 3 · engaged 4 · hot 5 · very_hot 7. Bloqueio retorna 409 CONFLICT com error_code: "BLOCKED_TEMPERATURE_LIMIT" (payload inclui remote_jid, score, tier, max_consecutive, outbound_consecutive).
    • Quando anti_spam_temperature_enabled=false → vale o limite fixo max_outbound_without_inbound (1-10, default 2), rolling window 7d. Cada inbound *events.Message zera o contador via worker. Bloqueio retorna 409 CONFLICT com error_code: "BLOCKED_NO_RECIPROCITY" (payload inclui remote_jid, count, threshold).

    Em ambos os casos, o contador subjacente é zerado a cada inbound do contato.

Resumo da precedência:

anti_spam_guard_enabled anti_spam_temperature_enabled Conteúdo duplicado Reciprocidade — limite consecutivo
false qualquer desativado desativado
true false ativo (hash 24h) ativo — limite fixo max_outbound_without_inbound
true true ativo (hash 24h) ativo — curva por score (temperatura), limite fixo ignorado

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


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

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

Auth: Owner, Admin

Request (opcional):

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

Resposta 200: Objeto de instância com QR code.


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

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

Auth: Owner, Admin

Resposta 200: Objeto de instância atualizado.


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

Reinicia a conexão da instância.

Auth: Owner, Admin

Resposta 200: Objeto de instância atualizado.


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

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

Auth: Owner, Admin

Resposta 200: Objeto de instância atualizado.


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

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

Auth: Owner, Admin

Request (opcional):

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

Resposta 200:

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

Notas:

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

Erros:

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

DELETE/v1/instances/{instanceId}#

Remove a instância permanentemente.

Auth: Owner, Admin

Resposta 204: Sem corpo.


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

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

Auth: Owner, Admin

Resposta 200:

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

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


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

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

Auth: Owner, Admin

Headers recomendados para proxies reversos:

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

Resposta: Server-Sent Events

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 BiaZap Console. Os usuários que apenas fazem polling em GET /qr ou abrem o SSE sem uma estrategia ficam com telas lentas, QR "parado" e retry manual irritante — todos esses problemas são resolvidos com o padrão abaixo.

Maquina de estados da instância#

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 BiaZap Console e usar fetch() com ReadableStream, o que permite headers e cancelamento via AbortController.

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

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

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

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

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

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

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

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

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

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

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

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

Alternativa mais simples: se você não quiser escrever o parser manual, use a biblioteca @microsoft/fetch-event-source. 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 BiaZap Console.