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:
{
"name": "Atendimento Principal"
}
Resposta 201:
{
"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:
[
{
"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_erroraparece so quando a instância teve problema (limpo ao conectar);ban_expiry/ban_reasonso após ban temporario;disconnected_at/offline_duration_seconds/offline_duration_human/offline_alert_levelso quando offline;last_logged_out_401_atso após restrição 401;proxyso quando ha IP dedicado vinculado.tiere calculado em tempo de resposta:healthy,stale,degraded,offline,critical_offline,intentional,pending,banned.GET /v1/instancesNÃO retornauptime— esse campo aparece apenas emGET /v1/instances/{instanceId}.
GET/v1/instances/{instanceId}#
Retorna detalhes de uma instância.
Auth: Owner, Admin
Resposta 200:
{
"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_urlaparece apenas quando ha avatar em cache.uptimeaparece apenas quandostatus=CONNECTED. Diferente da listagem, este objeto NÃO retornalast_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:
{
"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-namefora do tamanho permitido (3-80)404 INSTANCE_NOT_FOUND- instância não pertence a empresa autenticada409 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
200com 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:
{
"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:
{
"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_inboundfora do range 1-10404 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:
-
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).
- Texto: hash do
-
Reciprocidade (rolling 7d, escopo
(instance_id, remote_jid)):- Cada envio outbound incrementa o contador.
- Cada inbound
*events.Messagezera o contador via worker. - Bloqueia quando o contador ultrapassa
max_outbound_without_inbound.
Bloqueio: retorna 409 CONFLICT com:
error_code: "BLOCKED_DUPLICATE_CONTENT"— payload incluiremote_jid,content_hash,first_sent_at.error_code: "BLOCKED_NO_RECIPROCITY"— payload incluiremote_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):
{
"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):
{
"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:
{
"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.syncautomático do WhatsApp agora também e persistido e normalmente cria essas ancoras. - Mensagens históricas são salvas com
source="phone"para inbound esource="external"para outbound vindo do celular/WhatsApp Web. Elas não disparammessage.received/message.sentcomo 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álido404- Instância não encontrada400- 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:
{
"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-instanceIdausente404/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:
{
"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-instanceIdausente500- 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:
{
"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-instanceIdausente500- 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:
{
"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-instanceIdausente500- 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:
{
"instance_id": "84c2e480-...",
"company_id": 7,
"total": 1240,
"showing": 200,
"limit": 200,
"lines": ["{\"level\":\"info\",...}"]
}
Erros:
400-instanceIdausente404- arquivo de log não encontrado503- 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:
{
"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-instanceIdausente403 FORBIDDEN- contexto tenant ausente404 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:
{
"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-instanceIdausente404 INSTANCE_NOT_FOUND- instância não encontrada503 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:
{
"qr_code": "2@abc123...",
"qr_base64": "iVBORw0KGgo...",
"instance_id": "84c2e480-..."
}
O campo
qr_base64contem a imagem PNG do QR em base64. Se a instância não tiver QR pendente, retorna400.
GET/v1/instances/{instanceId}/qr/stream#
Stream SSE de QR codes. O servidor envia QR codes conforme são gerados pelo WhatsApp (~20s cada, até 6 por sessão). Quando o usuário escaneia, envia success. Se todos os QRs expiram, envia timeout.
Auth: Owner, Admin
Headers recomendados para proxies reversos:
X-Accel-Buffering: no(já incluído na resposta)- Desabilitar compressao (
no-gzip) para evitar buffering do SSE
Resposta: Server-Sent Events
event: connected
data: {"instance_id": "84c2e480-..."}
event: code
data: {"event": "code", "qr": "2@abc123...", "qr_base64": "iVBORw0KGgo...", "mime_type": "image/png"}
event: success
data: {"event": "success"}
event: timeout
data: {"event": "timeout"}
event: error
data: {"event": "error", "message": "descricao do erro"}
| Evento | Descricao |
|---|---|
connected |
Stream iniciado (enviado imediatamente) |
code |
Novo QR code disponível. qr = raw string, qr_base64 = imagem PNG base64 |
success |
Usuário escaneou o QR, instância pareada |
timeout |
Todos os QR codes expiraram sem leitura |
error |
Erro ao gerar QR |
Nota: O campo
qr_base64contem a imagem PNG do QR pronta para exibir:<img src="data:image/png;base64,${qr_base64}">. O campoqrcontem a string raw para gerar o QR localmente.
Proxy reverso: Se usar Apache/Nginx como proxy, desabilite compressao e buffering para endpoints SSE (
/qr/streame/events). Sem isso, o browser recebeERR_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:
{
"phone": "554137984905"
}
Resposta 200:
{
"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#
CREATED ──POST /connect──► CONNECTING ──► QR_PENDING ──(scan)──► CONNECTED
│ │
▼ ├─(timeout 6 QRs)─► TIMED_OUT
DISCONNECTED │
└─(erro)──────────► DISCONNECTED
| Estado | Significado | Ação do cliente |
|---|---|---|
CREATED |
Instância criada, ainda não tentou conectar | POST /connect para iniciar |
CONNECTING |
Abrindo websocket com WhatsApp, gerando primeiro QR | Abrir /qr/stream e esperar evento code |
QR_PENDING |
Pelo menos um QR já disponível | Exibir QR e aguardar success/timeout |
CONNECTED |
Pareado e recebendo mensagens | Fechar modal, liberar a tela |
TIMED_OUT |
6 QRs expiraram sem leitura (~2min total) | POST /restart e reabrir stream, ou pedir ao usuário para tentar de novo |
DISCONNECTED |
Desconectada (manual ou erro) | POST /connect para reativar |
BANNED |
Banida pelo WhatsApp | Não reconecte automaticamente — veja seção 16, evento instance.banned |
O WhatsApp gera um novo QR a cada ~20 segundos e aceita até 6 QRs por sessão de pareamento (totalizando ~2 minutos). Esses QRs chegam automaticamente no /qr/stream via evento code — não existe endpoint "próximo QR". Se você perder esse stream, precisa reiniciar (POST /restart) para reabrir a sessão.
Fluxo recomendado (5 passos)#
1. POST /v1/instances/{id}/connect ◄── dispara QR generation
2. GET /v1/instances/{id}/qr (snapshot) ◄── tela nao fica branca
3. GET /v1/instances/{id}/qr/stream (SSE) ◄── recebe novos QRs + success/timeout
4. GET /v1/instances/{id}/events?events=connection.update (SSE paralelo) ◄── rede de seguranca
5. Ao receber "success" ou CONNECTED: fechar streams, mostrar sucesso
A combinação /qr/stream + /events em paralelo e a diferença entre "funciona na maioria das vezes" e "funciona sempre". Se um proxy reverso engolir o evento success do /qr/stream, a transicao CONNECTING → CONNECTED em connection.update ainda chega pelo /events e você fecha o modal corretamente.
Contrato do /qr/stream#
O servidor envia eventos nomeados SSE conforme a seção GET /v1/instances/{instanceId}/qr/stream. 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 recebe429 Too Many Requests. Sempre useAbortControllerpara 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.
/**
* 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 /qrantes 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 APIfetchEventSource()identica aoEventSourcenativo mas com suporte a headers, retry automático eAbortController.
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:
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:
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.).
// 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.
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:
- Requer
POST /connectANTES. A chamadaPOST /pairing-codeso funciona se a instância estiver emCONNECTINGouQR_PENDING— caso contrario retorna400 instance not connected, call connect first. Na prática, execute o mesmo fluxo do QR e apenas troque a interface. - Código expira em ~60 segundos. Use um timer no cliente, mostre a contagem regressiva e permita gerar um novo código.
- Sucesso chega pelo mesmo
/events?events=connection.update— não ha stream dedicado para pairing. Mantenha a subscricao deconnection.updateativa e feche a UI quando vierstatus: CONNECTED.
// 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.