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",
"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_erroraparece quando a instância teve um problema (ex: conflito de número). E limpo automaticamente quando a instância conecta com sucesso. Os camposban_expiryeban_reasonaparecem 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:
{
"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:
{
"instance_id": "84c2e480-7c70-4c43-9bb3-f8e5f9ef2e53",
"humanize_default": {
"typing_ms": 500
},
"hygiene_skip": false,
"require_inbound_first": true
}
Notas:
require_inbound_first=truefaz o worker bloquearSend*para chats sem inbound previo na janela de 30 dias, retornandoERR_CHAT_NOT_INITIATEDantes 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):
{
"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 atualizavel404- 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:
{
"profile": "safe",
"configured": false
}
Notas:
configured=falsesignifica que a empresa ainda não salvou um perfil explicito; o frontend deve tratarsafecomo 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:
{
"profile": "max"
}
| Campo | Tipo | Obrigatório | Descricao |
|---|---|---|---|
profile |
string | sim | Um de test, safe, max |
Resposta 200:
{
"profile": "max",
"configured": true
}
Erros:
400- JSON inválido ouprofilefora detest|safe|max403- 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:
{
"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-limitinválido ou datas fora de RFC3339 /YYYY-MM-DD404- 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-8format=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-formatfora decsv|pdfou datas inválidas404- 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:
{
"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=trueindica 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 encontrada502- 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:
{
"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 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:
{
"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_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).
- Reset por inbound: quando o contato responde (qualquer
*events.MessagecomIsFromMe=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.
- Texto: hash do
-
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):cold2 ·warm3 ·engaged4 ·hot5 ·very_hot7. Bloqueio retorna409 CONFLICTcomerror_code: "BLOCKED_TEMPERATURE_LIMIT"(payload incluiremote_jid,score,tier,max_consecutive,outbound_consecutive). - Quando
anti_spam_temperature_enabled=false→ vale o limite fixomax_outbound_without_inbound(1-10, default 2), rolling window 7d. Cada inbound*events.Messagezera o contador via worker. Bloqueio retorna409 CONFLICTcomerror_code: "BLOCKED_NO_RECIPROCITY"(payload incluiremote_jid,count,threshold).
Em ambos os casos, o contador subjacente é zerado a cada inbound do contato.
- Quando
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):
{
"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):
{
"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 |
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 contínua 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}/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 BiaZap Console. Os usuários que apenas fazem polling em GET /qr ou abrem o SSE sem uma estrategia ficam com telas lentas, QR "parado" e retry manual irritante — todos esses problemas são resolvidos com o padrão abaixo.
Maquina de estados da instância#
CREATED ──POST /connect──► CONNECTING ──► QR_PENDING ──(scan)──► CONNECTED
│ │
▼ ├─(timeout 6 QRs)─► TIMED_OUT
DISCONNECTED │
└─(erro)──────────► DISCONNECTED
| Estado | Significado | Ação do cliente |
|---|---|---|
CREATED |
Instância criada, ainda não tentou conectar | POST /connect para iniciar |
CONNECTING |
Abrindo websocket com WhatsApp, gerando primeiro QR | Abrir /qr/stream e esperar evento code |
QR_PENDING |
Pelo menos um QR já disponível | Exibir QR e aguardar success/timeout |
CONNECTED |
Pareado e recebendo mensagens | Fechar modal, liberar a tela |
TIMED_OUT |
6 QRs expiraram sem leitura (~2min total) | POST /restart e reabrir stream, ou pedir ao usuário para tentar de novo |
DISCONNECTED |
Desconectada (manual ou erro) | POST /connect para reativar |
BANNED |
Banida pelo WhatsApp | Não reconecte automaticamente — veja seção 16, evento instance.banned |
O WhatsApp gera um novo QR a cada ~20 segundos e aceita até 6 QRs por sessão de pareamento (totalizando ~2 minutos). Esses QRs chegam automaticamente no /qr/stream via evento code — não existe endpoint "próximo QR". Se você perder esse stream, precisa reiniciar (POST /restart) para reabrir a sessão.
Fluxo recomendado (5 passos)#
1. POST /v1/instances/{id}/connect ◄── dispara QR generation
2. GET /v1/instances/{id}/qr (snapshot) ◄── tela nao fica branca
3. GET /v1/instances/{id}/qr/stream (SSE) ◄── recebe novos QRs + success/timeout
4. GET /v1/instances/{id}/events?events=connection.update (SSE paralelo) ◄── rede de seguranca
5. Ao receber "success" ou CONNECTED: fechar streams, mostrar sucesso
A combinação /qr/stream + /events em paralelo e a diferença entre "funciona na maioria das vezes" e "funciona sempre". Se um proxy reverso engolir o evento success do /qr/stream, a transicao CONNECTING → CONNECTED em connection.update ainda chega pelo /events e você fecha o modal corretamente.
Contrato do /qr/stream#
O servidor envia eventos nomeados SSE conforme a seção GET /v1/instances/{instanceId}/qr/stream. 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 BiaZap 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 BiaZap Console.