Números não-oficiais pelo seu app
Um número não-oficial é aquele que você conecta lendo um QR Code no WhatsApp do celular — sem Meta, sem Cloud API, sem App Review. Este guia mostra como fazer isso inteiramente pela API pública, dentro da sua própria interface, sem mandar ninguém para o painel da Pilot Status. São só duas peças:- Seu backend cria o número com
POST /v1/numbers— e o primeiro QR Code já vem nessa mesma resposta. - Sua tela desenha esse QR, renova quando ele vence e descobre que conectou (por polling ou por webhook).
ps_ e frontend React/Vite ligados exatamente a este fluxo: public-api-demo.zip. Descompacte, ponha a sua chave, npm run dev (ou docker compose up). Ele tem três telas — Números (criar, modal de QR e status ao vivo), Enviar (texto livre ou template, com um modal “Ver cURL”) e Webhooks (entregas e eventos recebidos localmente).Arquitetura
ps_ nunca aparecer no navegador.
O que você precisa
Uma chave de APIps_, copiada do painel em /profile, aba API.
Para criar o número, tanto uma chave de tenant quanto uma de número funcionam — essa rota não tem trava de escopo. Para enviar mensagens depois, a regra é outra: veja o passo 4.
403 { "error": "Creating a number is not available for a per-number connection", "code": "NUMBERS_GRANT_NOT_ALLOWED" }. Use uma chave de tenant.O fluxo
Crie o número — e receba o primeiro QR
POST /v1/numbers exige apenas name (1 a 60 caracteres) e number (mínimo 10 caracteres, só dígitos com + opcional).O name não passa por trim e não tem restrição de caracteres — o valor é gravado exatamente como enviado, então um espaço no começo ou no fim sobrevive. No number, o mínimo de 10 caracteres conta o +, então +123456789 (9 dígitos) passa e 123456789 não. Separadores não são aceitos: +55 11 99999-9999 devolve 400.linkToApiKey (boolean, descontinuado — sem efeito), piiMode (RELAY_ONLY | STORE_X_DAYS | STORE_INDEFINITE) e piiRetentionDays (inteiro de 1 a 3650, só junto com STORE_X_DAYS).linkToApiKey foi aposentado. Ele continua sendo aceito — mandar o campo não é erro e nada rejeita a requisição —, mas não faz mais nada: linkedApiKeyId na resposta é sempre null. Antes ele re-apontava uma chave com escopo de número para o número recém-criado, tirando-a em silêncio do número ao qual estava presa. A aposentadoria vale nas três rotas de provisionamento: POST /v1/numbers, POST /v1/numbers/meta e POST /v1/numbers/remote-pairing.Em nenhum caso este endpoint cria uma chave: nada no caminho de criação grava uma linha de ApiKey.provider nesta requisição. O endpoint já fixa o provedor não-oficial. O provider: "PILOT_STATUS" que você vê é da resposta, não do corpo que você envia.instanceName é gerado pela Pilot Status no formato PS-{dígitos}-{n}, em que {n} é o próximo índice livre daquele número na sua conta (uma segunda conexão do mesmo número vira PS-5511999999999-1). Ele nunca vem do name, e o name não é “slugificado”: é gravado literalmente como displayName e devolvido em displayName e name.Você pode enviar number com ou sem +; um único + inicial é removido e tudo que a API grava e devolve (instance.number, GET /v1/numbers) são só dígitos. O + só reaparece no webhook number.created, cujo campo phone vem em E.164 completo (+5511999999999).Todo id desta resposta é um cuid de 25 caracteres — letras minúsculas e dígitos, sempre começando com c, sem prefixo (cmm04obm46zz0qv4ycjp8x6r2). Vale para instance.id, tenantId e linkedApiKeyId, então não tente distinguir um do outro pelo prefixo. O único valor com prefixo na API é a própria chave: ps_ seguido de 48 caracteres hexadecimais.linkedApiKeyId é sempre null hoje — o vínculo de chave foi aposentado (veja linkToApiKey acima), então nenhuma chave é vinculada aqui. Significa também que o número novo não tem chave de API própria — POST /v1/numbers nunca cria uma.Guarde instance.id: é o id da instância WhatsApp, e é ele que todas as chamadas seguintes deste guia usam.Outros erros: 400 { "error": "Validation error", "details" }, 409 { "error": "Number already exists" }, 502 { "error": "Failed to create instance", "details" } e 401 quando não há credencial.O 409 é comparação exata dos dígitos, dentro da sua própria conta: +5511999999999 e 5511999999999 são o mesmo número, mas 5511987654321 e 551187654321 (o nono dígito opcional brasileiro) são números diferentes — os dois podem ser criados e cada um consome um slot do plano. Um número já conectado em outra conta Pilot Status não devolve 409, e sim outro erro 4xx; trate qualquer 4xx na criação como “número indisponível”. Se você precisa de tolerância ao nono dígito, use POST /v1/numbers/check, que é o único endpoint que testa as duas variantes.Desenhe o QR na sua tela e renove quando precisar
qrcodeBase64 é um PNG e chega como data URL completa (data:image/png;base64,iVBORw0KGgo…), pronta para ir direto no <img src={qr}> — é assim que o demo faz. A Pilot Status não monta nem normaliza essa string: ela é o campo do próprio provedor não-oficial, repassado byte a byte (só com trim). O prefixo é garantia do provedor, não nossa — por isso a própria interface da Pilot Status ainda checa antes de renderizar. Mantenha a checagem:null. O texto bruto do QR (o que uma biblioteca de QR codificaria) nunca é exposto — só o PNG já renderizado.QR Code do WhatsApp expira rápido. Para pegar um novo (ou um novo código de pareamento), chame GET /v1/numbers/{id}/connect com o instance.id:string | null:XXXX-XXXX. O alfabeto é 123456789ABCDEFGHJKLMNPQRSTVWXYZ: não existe zero nem I, O ou U, e nunca vem em minúsculas. A Pilot Status repassa a string do provedor sem alterar — não insere o hífen, não completa nem muda a caixa. Exiba o valor como veio e não valide com um regex mais estrito que ^[1-9A-HJ-NP-TV-Z]{4}-[1-9A-HJ-NP-TV-Z]{4}$.O código de pareamento é gerado para o número gravado na instância — o mesmo number que você mandou no POST /v1/numbers. O /connect não aceita corpo, header nem query param, então não dá para pedir um código para outro telefone; ele só funciona digitado naquele número exato.Não há campo de estado aqui — para saber se conectou, use o passo 3.null (nunca os dois, isso é o 502 mais abaixo). O pairingCode volta null quando a chamada de pareamento do provedor falha ou responde com um código vazio — em geral porque a sessão ainda não está de pé, ou porque o número gravado não é um número internacional válido (o provedor recusa números com 6 dígitos ou menos e números começando com 0, mesmo que o POST /v1/numbers os aceite). Sempre desenhe o QR como caminho principal e o código como alternativa; nunca monte um fluxo que exija a presença do pairingCode. Se você vir alguma sugestão de ?pairingCode=1 por aí, ignore — não existe.CONNECTING, mas o GET /status vai reportar CLOSE — o poll seguinte substitui esse valor interno pela leitura ao vivo do provedor. A Pilot Status não conta nem limita chamadas a essa rota.Erros: 409 { "error": "Instance already connected", "state": "OPEN" }, 404 { "error": "Not found" } quando o id não é do seu tenant, 502 { "error": "Failed to generate QR code", "details": "WhatsApp provider returned no QR code and no pairing code", "code": "EMPTY_CONNECT_BUNDLE" } quando nem QR nem código voltaram, 502 em falha do provedor e { "error": "WhatsApp provider not configured" } — 500 quando o provedor não-oficial não está configurado, 502 quando a própria chamada de connect reporta isso.Saiba quando conectou
{ id, state, stale } e ou checkedAt ou lastKnownAt (ambos em ISO). No 200 existem apenas dois valores na prática: OPEN (pareado) e CLOSE (qualquer outra coisa — nunca escaneado, QR na tela, QR expirado ou queda depois de conectado; o endpoint não distingue esses casos). Este endpoint nunca devolve CONNECTING em um 200: o estado connecting do provedor é normalizado para CLOSE antes da resposta. Faça polling até state === "OPEN" e trate qualquer outro valor como “ainda não”.Falha de sincronização devolve 503 { "error", "state", "code" }. O state dessa resposta não é leitura ao vivo — é o último valor gravado; só aí podem aparecer CONNECTING, LOGOUT ou connecting em minúsculas. Decida pelo code, nunca pela mensagem: PROVIDER_NOT_CONFIGURED é permanente — pare o polling e leve o caso para um operador; UPSTREAM_TIMEOUT e UPSTREAM_ERROR são passageiros — espere um pouco e tente de novo. Id inexistente devolve 404 { "error": "Not found" }, e o corpo do 404 não traz code.Cada GET /status faz uma chamada ao vivo ao provedor (timeout de 7 s) — nada é cacheado e nada espera por webhook de entrada. O primeiro poll depois de a sessão subir já devolve OPEN.O stale diz se aquela leitura é ao vivo. Com stale: false vem o checkedAt — o instante em que sondamos o provedor de fato. Com stale: true vem o lastKnownAt: o provedor não respondeu àquele poll e o valor devolvido é o último que guardamos. Uma resposta stale é sempre OPEN, porque o estado lembrado só é servido quando ele era OPEN. Número Meta é um caso à parte: nada é sondado para ele, então a resposta é { id, state: "OPEN", stale: false }, sem nenhum dos dois carimbos de tempo.OPEN — é a escolha dele, não uma exigência da API.503 "WhatsApp provider not configured" para todo número que ainda não estivesse OPEN sempre que o número da própria plataforma estivesse desconectado. Esse acoplamento acabou.number.connected também. Ele é disparado no tratador de conexão do provedor, ou seja, cobre este caminho não-oficial, e chega mesmo com ninguém olhando a sua tela. Três coisas para desenhar em volta dele.Ele dispara só no primeiro pareamento bem-sucedido daquele número — um novo pareamento do mesmo número depois é suprimido pela camada de deduplicação de ingestão, então nunca trate a ausência de number.connected como “não conectou”; o GET /v1/numbers/{id}/status é a fonte da verdade. Ele é entregue uma única vez, sem reentrega — se o seu endpoint responder 5xx ou estourar o tempo, o evento não é reenviado (a tentativa falha fica registrada no log de entregas). E agora ele tem um par funcional para quedas: o number.disconnected passou a disparar para números não-oficiais (web / EVO_GO). Antes disso, um número web que caía não gerava webhook assinável nenhum. Ele é emitido pela transição de saúde do número, que é quem controla o anti-flap e a deduplicação de um alerta por transição — ou seja, não é um evento por oscilação de socket. Você ainda pode assinar com "events": ["*"] para receber também number.health_blocked (todas as conexões do número fora do ar, detectado por um healthcheck periódico — espere minutos, não segundos) e number.recovered. Esses dois nomes são recusados se você listá-los explicitamente: só o curinga * os entrega.O corpo que você recebe:createdAt é o momento do disparo do evento, não a data de criação do número. Se o webhook tiver segredo, o corpo vem assinado em x-pilot-status-signature (HMAC-SHA256 em hexadecimal). Não existe header Idempotency-Key neste evento.GET /v1/numbers/{id}/status para conduzir um modal que está na frente da pessoa e como fonte da verdade do estado conectado/desconectado; use o webhook para acordar trabalho quando ninguém estiver com a sua tela aberta.POST /v1/numbers, os únicos eventos de conexão assináveis são number.created, number.connected, number.disconnected e number.removed (além de message.* e call.*). O connection.update e os nomes nativos da Evolution (Connected, LoggedOut) não são assináveis para esses números e são descartados em silêncio se você os pedir no array events.Envie por esse número
POST /v1/messages/send age sobre um número, então ele exige uma chave com escopo de número. Uma chave de tenant sozinha leva 403:error é uma única string bilíngue (EN | PT); trate sempre pelo code.Há um atalho: uma chave de tenant que também mande o header x-whatsapp-number-id fica restrita àquele número e funciona normalmente. É este o caminho para números criados com uma chave de tenant, que não têm chave própria. Um id que não resolve devolve 404 { "error": "x-whatsapp-number-id does not name a WhatsApp number of this account | x-whatsapp-number-id não indica um número de WhatsApp desta conta", "code": "NUMBER_NOT_FOUND" } — o code é a parte estável.{ "destinationNumber", "text" }. O destinationNumber aceita com ou sem +, mas só dígitos — +55 11 98888-7777 é recusado com 400 Validation error, assim como qualquer valor com menos de 10 dígitos.Texto livre em número não-oficial não tem janela de atendimento de 24 horas nem exigência de template — essas travas existem apenas para números oficiais da Meta. As únicas recusas de envio que valem aqui são 422 BILLING_SUSPENDED e 429 Rate limit exceeded.O sucesso é 202 — um enfileiramento, não uma entrega síncrona:id é um cuid (sem prefixo msg_), correlationId é sempre cid_ + 32 caracteres hexadecimais, status no 202 é sempre QUEUED, sourceNumber é o número remetente em dígitos puros — sem + —, e origin é apenas um rótulo legível da instância usada (nunca a string "API"); para identificar o remetente use sourceNumber, não origin.O campo media só vale no modo de mídia direta (nunca junto com text) e aceita uma URL http(s) ou um data URI base64 (data:<mime>;base64,…) de até 16 MB decodificados.Descobrir a chave do número por código
É o que o demo faz.GET /v1/api-keys é o endpoint de revelação e exige credencial com capacidade de tenant — uma chave de número recebe 403 { "error": "This endpoint requires a tenant-scoped API key", "code": "NUMBER_SCOPE_NOT_ALLOWED" }. Ele devolve um array de { numberId, number, displayName, keyId, keyLast4, key, revealable }, onde key é o valor real descriptografado.key só traz o valor real quando revealable é true. Vem null com revealable: false em dois casos: a chave foi criada antes de a criptografia reversível estar ativa (não há o que descriptografar) ou o texto cifrado guardado não descriptografa mais.É um item por número, não por chave: quando o número tem várias chaves — o que é comum —, só a mais recente aparece na lista. As antigas não são revogadas: continuam autenticando normalmente, apenas deixam de ser listadas aqui. Chaves sem número vinculado continuam aparecendo uma a uma.Um número sem chave de número não aparece nessa lista — ele não vem com revealable: false, simplesmente não vem. Se você criou o número com uma chave de tenant, esse é o caso normal.number), não só pelo numberId — para números não-oficiais o GET /v1/numbers devolve o id da instância, que não é o numberId desta resposta. O numberId aqui é o id do WhatsAppNumber, um valor diferente do instance.id que você guardou. Guarde os dois se precisar chamar /v1/numbers/{id} (id do número) e também /connect ou /status (id da instância).Como plano B, POST /v1/api-keys { "whatsappNumberId": "..." } devolve { numberId, keyId, keyPrefix, keyLast4, key, createdAt }. Se o número ainda não tinha chave, isso apenas cria a primeira e nada é invalidado.Receba mensagens
number.connected junto com os eventos de mensagem e você fecha o ciclo do passo 3 no mesmo endpoint — lembrando que ele chega uma única vez, no primeiro pareamento, e sem reentrega.Quando quem tem o celular não está na sua tela
Às vezes a pessoa que precisa apontar a câmera para o QR não é quem está usando o seu produto. Para esse caso existe uma alternativa hospedada:POST /v1/numbers/remote-pairing gera um link para uma página da Pilot Status que mostra o QR e conduz o pareamento.
Alguns pontos que importam:
provideré opcional e o padrão é"PILOT_STATUS"(o enum aceita"PILOT_STATUS"ou"META"), então o fluxo de QR é o comportamento padrão quando você omite o campo.- Para
PILOT_STATUS,nameenumbersão ambos obrigatórios — faltando um,400 { "error": "name and number are required for PILOT_STATUS remote pairing" }. - O token é um UUID puro (sem pontos), com TTL de 24 horas e de uso único: a rota de status o limpa assim que o polling vê
OPEN. (O token do fluxo Meta é outra coisa: um JWT assinado com TTL de 30 minutos. Não confunda os dois.) Uso único também quer dizer um link vivo por número: chamarPOST /v1/numbers/remote-pairingde novo para o mesmo telefone gera um token novo no mesmo número e invalida o link anterior em silêncio. E como o 201 não trazexpiresAt, calcule você mesmo o prazo de 24 h a partir do momento da chamada. - A resposta 201 traz
{ provider: "PILOT_STATUS", instance: { id, instanceName, number, displayName, state: "CLOSE" }, remotePairingUrl, maskedNumber, linkedApiKeyId: null }, mais um arraywarningsquando há algo a reportar (veja abaixo). Não háexpiresAtneste ramo — só o ramo Meta devolve esse campo. - A entrega do link é sua. O endpoint devolve a
remotePairingUrle nada acontece automaticamente: mande esse link para quem está com o celular pelo canal que fizer sentido no seu produto (SMS, e-mail, dentro do app). - Ele cria um
WhatsAppNumberreal e uma instância real no provedor (nada de placeholder no estilo Meta) e disparanumber.created. - Consome capacidade do plano, igual ao
POST /v1/numbers— a linha do número passa pela mesma checagem de capacidade antes de ser gravada. Acima do teto você recebe o mesmo402e os mesmos códigos doPOST /v1/numbers(esta rota respondia500antes):PLAN_NUMBER_LIMIT_REACHEDquando a parede é a cota do próprio plano,INSUFFICIENT_FUNDSquando o número é um extra pago que não dá para custear. O corpo carrega juntoplan,maxNumbers,currentNumberCount,extras,proratedTotal,walletBalanceecurrency. - Parear um número que você já tem não custa nada. Se já existir um número com os mesmos dígitos no seu tenant, ele é reaproveitado: nenhuma capacidade é consumida e nenhum
402de capacidade acontece. Mesmo assim uma nova conexão e um novo token de 24 h são criados, e onumber.createddispara de novo — então trate esse evento como pelo menos uma vez por link de pareamento, não como prova de número novo. - Aceita
branding. Já olinkToApiKeyé descontinuado e sem efeito aqui, exatamente como noPOST /v1/numbers: ele é aceito, nada é vinculado e olinkedApiKeyIdvoltanullsempre. externalReferedirectUrlvoltam reportados emwarnings. Eles viajam na sessão assinada da Meta, e o link de QR não tem sessão nenhuma — então, comprovider=PILOT_STATUS, são aceitos mas não têm como ser honrados. Cada um que você mandar volta como uma string no arraywarnings("externalRef is only supported for provider=META and was ignored"), e o pareamento acontece do mesmo jeito. Isso não é400: mandar esses campos não é erro; antes eles eram descartados em silêncio e agora são anunciados. Quando não há nada a reportar, a chave simplesmente não vem.- Se a sua credencial veio de OAuth por número, esta rota devolve o mesmo
codeda criação com outra mensagem:403 { "error": "Pairing a new number is not available for a per-number connection", "code": "NUMBERS_GRANT_NOT_ALLOWED" }.
remotePairingUrl da resposta e entregue você mesmo — por SMS, e-mail, ou o canal que fizer sentido no seu produto.
mode=button é exclusivo do Meta. Um token não-oficial resolve para o fluxo de QR e sempre renderiza a página hospedada completa, com a linha do tempo do pareamento. Não tente usar modo botão aqui. Enquanto o token ainda está sendo resolvido, um frame em mode=button chega a renderizar por um instante como um esqueleto de 48 px em formato de botão antes de virar a página inteira — então não dimensione o container assumindo altura de botão.E se você embutir a página: no fluxo de QR o evento connect:paired carrega null explícito em numberId, phone, provider, externalRef e redirectUrl — o displayName (o name que você mandou ao criar o link) é o único campo com valor. Não existe id de correlação nenhum no fluxo de QR, então case o evento com o seu cliente do lado do servidor, pelo instance.id do 201 ou pelo webhook number.connected. Atenção: o data.numberId desse webhook é o id do WhatsAppNumber, e não esse instance.id — case pelo campo phone ou guarde os dois ids lado a lado.Já connect:expired não carrega payload nenhum, e significa que o token acabou (expirou, nunca foi válido, ou já foi consumido por um pareamento bem-sucedido) — não é o QR da tela vencendo. A contagem “expira em Ns” da página é cosmética: ela é reiniciada a cada poll de status e nunca dispara evento. Recarregar a página hospedada depois de um pareamento bem-sucedido, portanto, mostra “link expirado” e emite connect:expired.Remover um número
DELETE /v1/numbers/{id} aceita tanto um id de WhatsAppNumber quanto um id de instância — é a única rota /v1/numbers/{id} que aceita os dois. Já GET e PATCH /v1/numbers/{id} são o oposto do /connect: eles resolvem só pelo id do WhatsAppNumber, então o instance.id devolve 404 { "error": "Number not found" } ali.
{ "ok": true }; id inexistente devolve 404 { "error": "Number not found" }. A remoção emite number.removed.
Remover libera a vaga, não o dinheiro. O número some e a vaga fica imediatamente reutilizável — você pode criar outro número no mesmo ciclo sem pagar de novo, desde que continue dentro da capacidade que já tem. O que a remoção não faz: não reduz sua capacidade paga de números extras, não devolve nem credita nada do ciclo atual, e não agenda nenhuma mudança. Se você não quer mais pagar por essa capacidade extra, reduza explicitamente com DELETE /v1/subscription/extra-numbers — a redução fica agendada e só vale a partir da virada do próximo ciclo, ainda sem estorno do ciclo corrente. Remova o número antes: uma redução que deixaria o limite abaixo dos números ainda conectados é recusada com 409 NUMBER_LIMIT_EXCEEDED.
Problemas comuns
402 ao criar o número — PLAN_NUMBER_LIMIT_REACHED ou INSUFFICIENT_FUNDS
402 ao criar o número — PLAN_NUMBER_LIMIT_REACHED ou INSUFFICIENT_FUNDS
402; o campo error é o que diz a saída, e só uma das duas é sobre dinheiro.PLAN_NUMBER_LIMIT_REACHED— o teto é a cota do próprio plano e você nunca comprou nem ganhou um número extra. Adicionar créditos ou salvar cartão não muda nada. Libere um número que não usa mais, ou suba de plano.INSUFFICIENT_FUNDS— o próximo número é um extra pago (plano que cobra por número, ou conta que já comprou extras) e não deu para custeá-lo. Adicione créditos ou salve um cartão e tente de novo.
plan, maxNumbers e currentNumberCount, mais proratedTotal, walletBalance e currency quando há preço a cotar — confira currentNumberCount contra maxNumbers antes de concluir qualquer coisa só pelo código.A vaga liberada por um DELETE é reutilizável sem custo adicional dentro do mesmo ciclo — mas remover o número não interrompe a cobrança recorrente da capacidade extra; para isso é preciso reduzir a capacidade explicitamente.INSUFFICIENT_FUNDS, o que mandava contas com carteira zerada procurar um dinheiro que não precisavam gastar. Mesmo status, código novo.409 Number already exists
409 Number already exists
id dele) em vez de criar outro.409 Instance already connected (state: OPEN)
409 Instance already connected (state: OPEN)
GET /v1/numbers/{id}/status, confirme o OPEN e siga para o envio.502 Failed to generate QR code — EMPTY_CONNECT_BUNDLE
502 Failed to generate QR code — EMPTY_CONNECT_BUNDLE
/connect, então chamar de novo é o caminho certo — e depois que a sessão de pareamento expira, é o caminho obrigatório. Uma sessão de pareamento rotaciona um número limitado de QR codes; quando esgota, o provedor desconecta a instância e limpa o QR, e o /connect seguinte inicia uma sessão nova. Chame GET /v1/numbers/{id}/connect de novo; se persistir, verifique o estado pelo /status. Um /connect “frio” pode levar alguns segundos, então faça polling em vez de um laço apertado.Um /connect que devolve QR com pairingCode: null não se conserta sozinho: o fallback interno que roda quando falta um dos dois campos só consegue buscar o QR de novo, nunca o código de pareamento. Se você precisa do código, chame /connect mais uma vez.500 ou 502 WhatsApp provider not configured
500 ou 502 WhatsApp provider not configured
429 Rate limit exceeded em POST /v1/messages/send
429 Rate limit exceeded em POST /v1/messages/send
{ "error": "Rate limit exceeded", "reason": "..." } com um de dois motivos: "No active subscription" (o tenant não tem assinatura ativa) ou "Plan limit reached (N messages)" (a franquia vitalícia de 200 mensagens do plano Free, mais os pacotes comprados, acabou). Planos pagos não têm limite de quantidade de mensagens. Resolva ativando uma assinatura ou adicionando créditos — repetir a chamada não desbloqueia.403 TENANT_SCOPE_NOT_ALLOWED ao enviar
403 TENANT_SCOPE_NOT_ALLOWED ao enviar
POST /v1/messages/send age sobre um único número. Ou use a chave com escopo daquele número, ou mande a chave de tenant junto com o header x-whatsapp-number-id. Se o id do header não resolver, você recebe 404 NUMBER_NOT_FOUND.403 NUMBER_SCOPE_NOT_ALLOWED em GET /v1/api-keys
403 NUMBER_SCOPE_NOT_ALLOWED em GET /v1/api-keys
403 NUMBERS_GRANT_NOT_ALLOWED ao criar
403 NUMBERS_GRANT_NOT_ALLOWED ao criar
404 em connect ou status
404 em connect ou status
/connect aceita somente o id da instância; o /status aceita o id da instância (e, apenas para números oficiais/Meta, o id do WhatsAppNumber). Use o instance.id devolvido na criação — não o numberId que aparece em GET /v1/api-keys, não o nome da instância, não o número de telefone. Um id errado sempre dá 404, nunca “funciona em silêncio”.503 no status — olhe o code antes de repetir
503 no status — olhe o code antes de repetir
{ error, state, code }, mas esse state é o último valor gravado, não uma leitura ao vivo — é o único lugar em que podem aparecer CONNECTING, LOGOUT ou connecting em minúsculas.Decida pelo code, nunca pela mensagem — nem todo 503 é passageiro:PROVIDER_NOT_CONFIGURED— permanente. Não se resolve sozinho, e um poller que insiste fica em loop para sempre. Pare o polling, avise um operador e fale com o suporte.UPSTREAM_TIMEOUT/UPSTREAM_ERROR— passageiro. Trate como “não sei”, espere um pouco e tente de novo.
404 não traz code nenhum — ali é “não encontrado”, não falha de sincronização.Um 5xx que não é JSON
Um 5xx que não é JSON
error code: 502). Nunca chame res.json() numa resposta que falhou sem proteção: cheque res.ok e o content-type antes, ou proteja o parse. Isso morde mais no /connect, que é a chamada mais lenta do fluxo e, por isso, a mais sujeita a ser respondida pela borda em vez da aplicação.O QR não renderiza na tela
O QR não renderiza na tela
qrcodeBase64 chega como data URL completa e você pode usá-lo direto em <img src={...}>. Como a Pilot Status repassa o campo do provedor byte a byte, sem montar nem normalizar o prefixo, renderize defensivamente: