Pareamento
Como um WhatsApp vira um dispositivo vinculado — por código no celular da pessoa (D3) ou por QR na tela do responsável —, com gates, checagem do número e reconexão.
Pareamento é o vínculo vivo entre um seat_channel (vaga × canal) e uma sessão
do provedor. Tudo mora em backend/src/domains/channels e não tem nome de
WhatsApp: a tabela pairings serve a qualquer canal, e o WahaAdapter é só o
adapter do WhatsApp.
Dois caminhos, uma sessão
Os dois caminhos passam por startPairing, com os mesmos gates e a mesma
alocação de proxy. Só muda quem vê o desafio (challenge_audience).
Decisão D3 do redesign: a conexão acontece no celular da própria pessoa, por código, dentro do link de consentimento.
- O responsável habilita o canal:
POST /seats/{uid}/channels/whatsapp(é cobrável, então só ele faz). - A pessoa consente no link e, na mesma página, chama
POST /consent-requests/{token}/pairing(sem corpo). - O backend pede ao WAHA um código para o número dela (
POST /api/{session}/auth/request-code). - Ela digita o código em WhatsApp → Aparelhos conectados → Conectar com número de telefone.
- A página segue
GET /consent-requests/{token}/events(SSE) atépaired.
O responsável nunca vê o código: no stream da conta ele recebe
challenge.kind = "code_on_dependent_phone".
Gates
Checados em startPairing e, no caminho público, em seatForPairingLink:
| Gate | Erro |
|---|---|
| Consentimento dado pela pessoa | 422 CONSENT_REQUIRED |
| Vínculo (background check) não refutado | 422 RELATIONSHIP_REFUTED |
| Canal habilitado na vaga | 422 CHANNEL_NOT_ENABLED |
| Nenhum pareamento vivo no canal | 422 PAIRING_EXISTS (rota do responsável) |
| Número da pessoa não é de um responsável da família ("porta nº 2") | 422 DEPENDENT_PHONE_IS_GUARDIAN |
| Algum endereço do pool com vaga | 422 NO_PROXY_CAPACITY |
| Link público: é o pedido de consentimento mais recente, vaga não arquivada, família ativa | 404 |
Estados
| Status | Significa | Vem de |
|---|---|---|
starting | sessão criada no WAHA | startPairing |
challenge | QR ou código disponível | SCAN_QR_CODE ou desafio buscado |
passkey | o WhatsApp mostrou um número no celular e quer um "confere?" | PASSKEY_CONFIRMATION_REQUIRED |
connecting | leu/digitou, sessão abrindo | STARTING vindo de challenge ou passkey |
paired | conectado e número conferido | WORKING + samePhone |
degraded | caiu depois de pareado; retry agendado | FAILED/STOPPED com paired |
disconnected | desistiu (fim) ou desafio que falhou antes de parear | ver abaixo |
cancelled | recusado por nós (identity_mismatch, passkey_refused, códigos esgotados, restarted) | workers/api |
revoked | desvinculado pelo responsável ou resetado por operador | DELETE /seats/{uid}/pairing, reset admin |
cancelled, revoked e o disconnected final gravam ended_at, chamam
destroy no adapter (logout + delete da sessão no WAHA) e publicam pairing.ended.
Passkey. Com passkey_confirm, só a resposta move o pareamento: o responsável
responde em POST /seats/{uid}/pairing/passkey, a pessoa em
POST /consent-requests/{token}/pairing/passkey (só se o desafio for dela).
matches: false encerra como passkey_refused: os números só diferem se outro
dispositivo está sendo vinculado. Já PASSKEY_REQUIRED (WebAuthn de
web.whatsapp.com) vira failed com passkey_required: não há como responder do
nosso lado, a pessoa resolve no site do WhatsApp e pareia de novo.
Códigos
- Um código pedido de novo anula o que está sendo digitado. Por isso o código vivo é servido da linha; um novo só é pedido quando falta ou expira (margem de 5 s).
- Cada pedido conta contra o número no WhatsApp: no máximo
MAX_CODES_PER_PAIRING = 5por pareamento. No sexto, o pareamento terminacancelledcomcode_attempts_exhausted(na página pública,failure: "expired"). - O valor do código não é guardado em
pairing_events; só oexpiresAt.
Em aberto
O adapter assume validade de 60 s para o código; o WAHA não documenta a vida real.
Checagem do número ao conectar
Em WORKING, identityOf(me) transforma 5511…@c.us em E.164 e onConnected
compara com o telefone da pessoa usando samePhone: para +55 compara DDD + 8
últimos dígitos, porque o WhatsApp mantém JIDs brasileiros antigos sem o nono
dígito. Número diferente:
- evento
pairing.identity_mismatchcom o telefone conectado mascarado; endPairing(…, "cancelled", "identity_mismatch"), que desfaz o vínculo no WAHA;- notificação
pairing.identity_mismatch(crítica), uma por pareamento.
Número certo: paired, reconnect_attempts = 0, last_seen_at e paired_at,
evento pairing.paired e, por último, a captura da foto do próprio número.
Reconexão com backoff
export const RECONNECT_DELAYS_MS = [30_000, 2 * 60_000, 10 * 60_000] as const;FAILED/STOPPEDcom o pareamentopaired(ou jádegraded) →degraded,failure_reasonenext_retry_at = agora + atraso[tentativa].- O
reconnectTicker(15 s) pega os vencidos, incrementareconnect_attempts, grava o eventoreconnecte chamarestart(POST /api/sessions/{name}/restart). - Voltou
WORKING→pairedde novo (sem notificar: a notificaçãopairing.pairedé uma por pareamento). - Depois da terceira tentativa,
endPairing(…, "disconnected", "gave up after 3 reconnect attempts: …")e notificaçãopairing.disconnected(crítica, uma por pareamento).
Queda durante starting/challenge/passkey/connecting não é reconexão: vai
para disconnected sem ended_at. Pelo link, um novo POST …/pairing encerra
essa tentativa como restarted e abre outra; pelo responsável, é preciso
DELETE /seats/{uid}/pairing antes de um novo POST.
Em aberto
O next_retry_at é zerado quando o restart é emitido. Se o WAHA não mandar
nenhum evento depois do restart, o pareamento fica degraded sem novo retry.
Não há timeout para esse caso no código.
Rotas
| Rota | Quem |
|---|---|
POST /seats/{uid}/channels/{channel} | dono, habilita o canal |
POST, GET, DELETE /seats/{uid}/pairing | dono: abrir, ler, desvincular |
POST /seats/{uid}/pairing/challenge | dono: QR/código novo; ?method=qr assume o fallback |
POST /seats/{uid}/pairing/passkey | dono: responde o número do celular |
GET /seats/{uid}/pairing/avatar | foto do número conectado |
GET /seats/{uid}/channels | canais e pareamentos (o último encerrado, se não há vivo) |
POST, GET /consent-requests/{token}/pairing | pessoa protegida, pelo link |
POST /consent-requests/{token}/pairing/passkey | pessoa protegida |
GET /consent-requests/{token}/events | SSE público, ping a cada 5 s |
POST /admin/seats/{uid}/pairing/reset | operador com escrita, motivo obrigatório |
As rotas públicas têm limite por link e por IP (POST …/pairing: 10 por link e
30 por IP a cada 10 min) e respondem 429 RATE_LIMITED com Retry-After. Detalhes
de contrato em Pareamento (Framework) e na API.
Fontes
backend/src/domains/channels/use-cases/{start-pairing,dependent-pairing,refresh-challenge,apply-provider-state,confirm-passkey,cancel-pairing,guardian-phone}.tsbackend/src/domains/channels/adapters/waha-adapter.ts,tables/pairings.table.ts,dtos/public-pairing.dto.tsbackend/apps/workers/{challenge-refresher,reconnect-ticker}.tsbackend/src/http/routes/channels/pairing.route.ts,backend/src/http/routes/consents/pairing.route.ts,backend/src/http/routes/admin/operator-lists.route.ts- Lane doc
k-pairing-api.md(G-23 / D3);.specs/features/redesign/{blueprint,api-map,api-gaps}.md(D3, G-23) - Vault: AD-010,
problems/2026-09-12-pairing-mismatch-silencioso,problems/2026-09-12-whatsapp-jid-sem-nono-digito
WAHA e GOWS
Como o Superdinda fala com o WhatsApp — WAHA self-hosted com a engine GOWS, uma sessão por pareamento e um fluxo orientado a eventos.
Proxies
De qual endereço cada sessão do WhatsApp sai — proxies ISP brasileiros de IP fixo, poucos por IP, comprados pela API do IPRoyal e controlados pelo backoffice.
