Skip to main content

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:
  1. Seu backend cria o número com POST /v1/numbers — e o primeiro QR Code já vem nessa mesma resposta.
  2. Sua tela desenha esse QR, renova quando ele vence e descobre que conectou (por polling ou por webhook).
Quer um ponto de partida pronto pra rodar? Baixe o demo (licença MIT) — backend Node/Express que guarda a chave 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

Só o seu backend fala com a API da Pilot Status. O browser conversa apenas com o seu backend — é assim que o demo faz, e é o motivo de a chave ps_ nunca aparecer no navegador.
Nunca coloque uma chave ps_ em código que roda no browser. Ela dá acesso à sua conta inteira. Todo exemplo deste guia que usa a chave é código de servidor — os blocos marcados “Sua tela” rodam no browser e nunca tocam na chave.

O que você precisa

Uma chave de API ps_, 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.
Se a sua chave veio de OAuth com consentimento por número, criar número é bloqueado: 403 { "error": "Creating a number is not available for a per-number connection", "code": "NUMBERS_GRANT_NOT_ALLOWED" }. Use uma chave de tenant.
Se ainda estiver decidindo entre número oficial e não-oficial, leia Oficial vs. não-oficial. Se preferir fazer tudo pelo painel, o passo a passo está em Conectar números.

O fluxo

1

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.
cURL
Campos opcionais: 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.
Não existe campo 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.
A resposta é 201 e já traz o QR:
O 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ópriaPOST /v1/numbers nunca cria uma.Guarde instance.id: é o id da instância WhatsApp, e é ele que todas as chamadas seguintes deste guia usam.
Criar o número já consome capacidade do plano. A checagem acontece antes de a linha ser gravada. Passando do teto, a chamada falha com 402 — e o campo error diz em qual parede você bateu: PLAN_NUMBER_LIMIT_REACHED quando a cota do próprio plano está cheia e você nunca comprou extra (libere um slot ou suba de plano; crédito não resolve), ou INSUFFICIENT_FUNDS quando o número é um extra pago que você não consegue custear (adicione créditos ou salve um cartão). O corpo traz plan, maxNumbers, currentNumberCount, proratedTotal e walletBalance — veja erros de capacidade. Dentro do teto já custeado, é liberado sem cobrança extra. É a mesma forma do fluxo Meta — “custa um slot no momento em que você cria” — só muda o erro.
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.
2

Desenhe o QR na sua tela e renove quando precisar

O 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:
Sua tela
Não existe parâmetro para pedir o payload sem prefixo: o campo é essa data URL ou 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:
A resposta tem exatamente dois campos, ambos string | null:
O código de pareamento tem sempre 9 caracteres: 8 caracteres base32 maiúsculos com um hífen após o quarto — 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.
Não existe parâmetro para pedir “só o código de pareamento”. A rota não aceita query param, header nem corpo: ela sempre pede os dois ao provedor e devolve o que vier — por isso qualquer um dos dois campos pode vir 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.
Como efeito colateral, uma chamada bem-sucedida coloca a instância em 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.
3

Saiba quando conectou

Dois caminhos, e eles servem a situações diferentes.Polling — bom quando a sua tela já está aberta na frente da pessoa:
cURL
A resposta traz { 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.
Não há intervalo mínimo imposto pelo servidor. O demo escolheu 3 segundos e para ao ver OPEN — é a escolha dele, não uma exigência da API.
Corrigido: este endpoint respondia 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.
Um 200 OPEN também pode vir do último estado conhecido quando o provedor não responde àquele poll (timeout da sondagem ou provedor inacessível) — é exatamente o caso stale: true, e o lastKnownAt mostra a idade daquele valor. Isso nunca inventa uma primeira conexão — antes do escaneamento o estado gravado não é OPEN, então a mesma falha devolve 503 —, mas significa que um poller de longa duração pode continuar vendo OPEN por um tempo depois de uma queda real. Assine number.disconnected para saber das quedas.
Webhook — pegue o 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:
Mudança quebrada — o data.numberId agora é sempre o id do WhatsAppNumber. Em todo webhook de cliente number.* (number.created, number.connected, number.disconnected, number.removed e os eventos de saúde), o data.numberId carrega o id do WhatsAppNumber. Antes ele vinha com o id da WhatsAppInstance em number.created / number.connected / number.removed e com o id do WhatsAppNumber nos eventos de saúde; agora está tudo normalizado. Se o seu consumidor casava os eventos de ciclo de vida pelo id da instância, ele para de casar — passe a usar o id do WhatsAppNumber (o numberId do GET /v1/api-keys, ou o id que o GET/PATCH /v1/numbers/{id} resolve) ou case pelo phone.
O 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.
Não conte com receber o number.connected enquanto você estiver fazendo polling. Ele só é emitido quando o evento de conexão do provedor encontra o nosso estado gravado ainda diferente de OPEN; um poll que virar o estado primeiro suprime o evento. Escolha um: polling para o modal, ou só webhook para o backend.
Regra de bolso: faça polling do 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.
Para números criados por 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.
4

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:
O 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.
cURL
O corpo mínimo é exatamente { "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.
Seu backend
Case também pelo telefone (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.
POST /v1/api-keys não rotaciona a chave no lugar — cria uma chave nova (novo keyId) e depois apaga todas as outras chaves de número daquele número, inclusive as criadas pelo painel ou pelos testes de template. Elas param de funcionar na requisição seguinte, sem carência. Se o número tiver uma inbox nativa do Chatwoot e o envio da chave nova para ela falhar, as antigas são mantidas de propósito para a inbox não quebrar — então, após uma rotação que falhou nesse passo, mais de uma chave pode continuar valendo. Aceita o id do número ou o id da instância e devolve o numberId canônico. Use como plano B, não como rotina.
5

Receba mensagens

Recepção é a mesma história de sempre: registre um webhook por número e trate as entregas. Não vamos repetir aqui — o assunto inteiro (lista de eventos, formato, reentregas) está em Receber mensagens e em Webhooks.Assine 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, name e number sã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: chamar POST /v1/numbers/remote-pairing de 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 traz expiresAt, 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 array warnings quando há algo a reportar (veja abaixo). Não há expiresAt neste ramo — só o ramo Meta devolve esse campo.
  • A entrega do link é sua. O endpoint devolve a remotePairingUrl e 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 WhatsAppNumber real e uma instância real no provedor (nada de placeholder no estilo Meta) e dispara number.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 mesmo 402 e os mesmos códigos do POST /v1/numbers (esta rota respondia 500 antes): PLAN_NUMBER_LIMIT_REACHED quando a parede é a cota do próprio plano, INSUFFICIENT_FUNDS quando o número é um extra pago que não dá para custear. O corpo carrega junto plan, maxNumbers, currentNumberCount, extras, proratedTotal, walletBalance e currency.
  • 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 402 de capacidade acontece. Mesmo assim uma nova conexão e um novo token de 24 h são criados, e o number.created dispara 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á o linkToApiKey é descontinuado e sem efeito aqui, exatamente como no POST /v1/numbers: ele é aceito, nada é vinculado e o linkedApiKeyId volta null sempre.
  • externalRef e redirectUrl voltam reportados em warnings. Eles viajam na sessão assinada da Meta, e o link de QR não tem sessão nenhuma — então, com provider=PILOT_STATUS, são aceitos mas não têm como ser honrados. Cada um que você mandar volta como uma string no array warnings ("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 code da criação com outra mensagem: 403 { "error": "Pairing a new number is not available for a per-number connection", "code": "NUMBERS_GRANT_NOT_ALLOWED" }.
cURL
Pegue a remotePairingUrl da resposta e entregue você mesmo — por SMS, e-mail, ou o canal que fizer sentido no seu produto.
Nunca fixe o host de conexão no seu código. A remotePairingUrl é montada a partir de NEXT_PUBLIC_CONNECT_HOST, ou CONNECT_PUBLIC_URL, ou a origem da requisição. Use exatamente a URL que a API devolveu, sem remontar.
Um pareamento que falha ainda pode consumir uma vaga. Diferente do POST /v1/numbers, esta rota não desfaz a criação do número quando o provedor recusa a instância (502 { "error": "Failed to create instance" } — diferente do POST /v1/numbers, esta rota não devolve o campo details). O número continua no tenant contando na capacidade — tentar de novo com o mesmo telefone reaproveita a linha (sem cobrar duas vezes), mas se você desistir precisa chamar DELETE /v1/numbers/{id} para liberar a vaga.
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.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.
cURL
Sucesso é 200 { "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

A capacidade do plano é consumida na criação, antes de a linha ser gravada. As duas recusas são 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.
O corpo traz 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.
Mudou. Uma cota de plano simplesmente cheia também respondia INSUFFICIENT_FUNDS, o que mandava contas com carteira zerada procurar um dinheiro que não precisavam gastar. Mesmo status, código novo.
Já existe um número com esse valor na sua conta. Use o número existente (busque o id dele) em vez de criar outro.
Você pediu um QR novo para uma instância que já está conectada. Não há o que parear: consulte GET /v1/numbers/{id}/status, confirme o OPEN e siga para o envio.
O provedor respondeu sem QR e sem código de pareamento. A Pilot Status não conta nem limita chamadas a /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.
O provedor não-oficial não está configurado no ambiente que atendeu a chamada — 500 quando o provedor não está configurado, 502 quando a própria chamada de connect reporta isso. Isso não se resolve do lado do cliente — fale com o suporte.
Apesar do nome, isso é uma cota de plano, não um limite por segundo — a Pilot Status não afunila a vazão de envio. O corpo é { "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.
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.
Esse endpoint exige credencial com capacidade de tenant. Uma chave com escopo de número não consegue listar nem revelar chaves — troque pela chave de tenant.
Sua credencial veio de OAuth com consentimento por número, e criar número não é permitido nesse caso. Use uma chave de tenant.
O id não pertence ao seu tenant, ou não é um id de instância. O /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”.
Falha ao sincronizar o estado com o provedor. A resposta traz { 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_CONFIGUREDpermanente. 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.
O corpo de um 404 não traz code nenhum — ali é “não encontrado”, não falha de sincronização.
Os corpos de erro da API são JSON, mas um 5xx também pode vir da borda que fica na frente dela — e esses são texto puro (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 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:

Criar número (referência)

Corpo completo, campos opcionais e todos os códigos de retorno.

Status do número

O endpoint de estado e o vocabulário completo.

Pareamento remoto

A alternativa hospedada, com todas as opções do corpo.

Receber mensagens

Webhooks por número, eventos e tratamento das entregas.