SuperdindaDocs

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.

  1. O responsável habilita o canal: POST /seats/{uid}/channels/whatsapp (é cobrável, então só ele faz).
  2. A pessoa consente no link e, na mesma página, chama POST /consent-requests/{token}/pairing (sem corpo).
  3. O backend pede ao WAHA um código para o número dela (POST /api/{session}/auth/request-code).
  4. Ela digita o código em WhatsApp → Aparelhos conectados → Conectar com número de telefone.
  5. 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:

GateErro
Consentimento dado pela pessoa422 CONSENT_REQUIRED
Vínculo (background check) não refutado422 RELATIONSHIP_REFUTED
Canal habilitado na vaga422 CHANNEL_NOT_ENABLED
Nenhum pareamento vivo no canal422 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 vaga422 NO_PROXY_CAPACITY
Link público: é o pedido de consentimento mais recente, vaga não arquivada, família ativa404

Estados

StatusSignificaVem de
startingsessão criada no WAHAstartPairing
challengeQR ou código disponívelSCAN_QR_CODE ou desafio buscado
passkeyo WhatsApp mostrou um número no celular e quer um "confere?"PASSKEY_CONFIRMATION_REQUIRED
connectingleu/digitou, sessão abrindoSTARTING vindo de challenge ou passkey
pairedconectado e número conferidoWORKING + samePhone
degradedcaiu depois de pareado; retry agendadoFAILED/STOPPED com paired
disconnecteddesistiu (fim) ou desafio que falhou antes de parearver abaixo
cancelledrecusado por nós (identity_mismatch, passkey_refused, códigos esgotados, restarted)workers/api
revokeddesvinculado pelo responsável ou resetado por operadorDELETE /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 = 5 por pareamento. No sexto, o pareamento termina cancelled com code_attempts_exhausted (na página pública, failure: "expired").
  • O valor do código não é guardado em pairing_events; só o expiresAt.

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:

  1. evento pairing.identity_mismatch com o telefone conectado mascarado;
  2. endPairing(…, "cancelled", "identity_mismatch"), que desfaz o vínculo no WAHA;
  3. 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

apply-provider-state.ts
export const RECONNECT_DELAYS_MS = [30_000, 2 * 60_000, 10 * 60_000] as const;
  1. FAILED/STOPPED com o pareamento paired (ou já degraded) → degraded, failure_reason e next_retry_at = agora + atraso[tentativa].
  2. O reconnectTicker (15 s) pega os vencidos, incrementa reconnect_attempts, grava o evento reconnect e chama restart (POST /api/sessions/{name}/restart).
  3. Voltou WORKING → paired de novo (sem notificar: a notificação pairing.paired é uma por pareamento).
  4. Depois da terceira tentativa, endPairing(…, "disconnected", "gave up after 3 reconnect attempts: …") e notificação pairing.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

RotaQuem
POST /seats/{uid}/channels/{channel}dono, habilita o canal
POST, GET, DELETE /seats/{uid}/pairingdono: abrir, ler, desvincular
POST /seats/{uid}/pairing/challengedono: QR/código novo; ?method=qr assume o fallback
POST /seats/{uid}/pairing/passkeydono: responde o número do celular
GET /seats/{uid}/pairing/avatarfoto do número conectado
GET /seats/{uid}/channelscanais e pareamentos (o último encerrado, se não há vivo)
POST, GET /consent-requests/{token}/pairingpessoa protegida, pelo link
POST /consent-requests/{token}/pairing/passkeypessoa protegida
GET /consent-requests/{token}/eventsSSE público, ping a cada 5 s
POST /admin/seats/{uid}/pairing/resetoperador 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}.ts
  • backend/src/domains/channels/adapters/waha-adapter.ts, tables/pairings.table.ts, dtos/public-pairing.dto.ts
  • backend/apps/workers/{challenge-refresher,reconnect-ticker}.ts
  • backend/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

On this page