Skip to main content

Embedded Signup no seu app com botão hospedado

Conecte o número WhatsApp oficial (Cloud API) do seu cliente sem tirá-lo do seu produto — e sem carregar o SDK do Facebook. Você embute a página de conexão hospedada da Pilot Status num <iframe> no modo botão (mode=button); o botão Continuar com o Facebook e o popup do Meta Embedded Signup já vivem dentro dessa página, servida por connect.pilotstatus.com.br. Seu app faz só duas coisas: o backend gera o link de conexão com a sua chave de tenant, e o frontend embute o iframe e escuta o resultado.
Quer um ponto de partida pronto pra rodar? Baixe o demo (licença MIT) — backend Node e frontend React ligados exatamente a este fluxo, com modelos de botão prontos: embedded-signup-demo.zip. Descompacte, ponha a sua chave ps_, npm run dev (ou docker compose up).
Esta página é o passo a passo: siga os quatro passos na ordem e você tem uma integração funcionando. A página irmã, Incorpore a Página de Conexão, é a referência — todas as opções, os dois providers (Cloud API e QR), o protocolo postMessage completo e a tabela cheia do branding.button. Esta página linka pra lá em vez de repetir, então você sempre lê uma única cópia.
Este é o caminho recomendado. Ele evita todo o trabalho de rodar o Embedded Signup na sua própria página. Seu app não carrega SDK nenhum do Facebook e não roda FB.login — não há nada de app Meta para você provisionar. Nenhum domínio seu precisa ser registrado num app Meta: o botão roda em connect.pilotstatus.com.br, um domínio já registrado no app Meta da Pilot Status.

Uma credencial só

Você precisa de exatamente uma coisa: uma chave de API ps_ com escopo de tenant. Pegue em /profile, aba API. Você não precisa de app Meta próprio, nem de App Review, nem de nenhuma configuração do lado da Meta, nem de chave com escopo de número. O Embedded Signup roda sobre o app da Pilot Status. A chave ps_ fica no seu servidor — o browser nunca chama a API da Pilot Status, ele só embeda o iframe.

Arquitetura

Só o seu backend toca na API da Pilot Status. O browser embeda o iframe e escuta um evento de message — nada mais.

Os quatro passos

1

Seu backend gera o link de conexão

Chame POST /v1/numbers/remote-pairing com provider: "META" e metaFlow: "embedded", usando a chave de tenant. Para estilizar o botão, mande um objeto branding.button:
cURL
Esse exemplo gera um link cuja página é só o caso mais pedido: botão verde WhatsApp (#25D366) com o logo do WhatsApp antes do rótulo, cantos arredondados e 56 px de altura.A resposta traz exatamente três campos:
Guarde a remotePairingUrl — é o link de conexão (connectUrl) que o frontend vai embutir. Devolva esse link (ou o token) ao seu frontend, nunca a chave ps_.
Confira em cinco segundos: cole a remotePairingUrl no navegador. Porque você mandou branding.button, esse link renderiza só o seu botão estilizado — o botão verde WhatsApp de 56 px do exemplo acima. É exatamente o que o seu cliente vê dentro do iframe no passo 2. (Em nível superior aparece também o marcador pequeno “secured by pilotstatus.com.br”; dentro de um iframe de verdade ele se esconde sozinho.)Viu um card inteiro, com cabeçalho e texto explicativo? Então o link não carrega branding.button — confira se você aninhou o objeto dentro de branding, e não no topo do corpo.
Vale ser preciso aqui, porque as duas metades moram em lugares diferentes:
  • Esta chamada decide o estilo e a forma. O branding.button é assinado dentro do token, e mandá-lo é também o que faz o link renderizar o botão puro em vez da página cheia. Um objeto, os dois efeitos.
  • Os parâmetros ?embed=1&parentOrigin=… do passo 2 decidem o encanamento. São eles que ligam o canal de postMessage de volta pra sua página. Sem eles o botão renderiza e funciona igual — você só nunca fica sabendo do resultado.
O que o branding.button aceita. Os dez campos são todos opcionais, e a tabela completa — valores, padrões, faixas, mais o hideProvenance — está na referência: Estilo do botão. Os três que mais derrubam gente:
  • O icon segue a variant quando você omite. facebook → glifo do Facebook, whatsapp → glifo do WhatsApp, qualquer outra variante → nenhum glifo. Mande "none" pra tirar de propósito.
  • O width substitui o fullWidth. Se você mandar largura, o fullWidth é ignorado, seja o que for que você pôs nele.
  • Valor inválido é recusado, não corrigido. Fora da faixa ou fora do enum devolve 400 Validation error com um details — e nenhum link, nenhum placeholder, nenhum slot consumido. height: 1000 não cai no padrão; derruba a chamada inteira.
O branding.button pode ser definido aqui, ao gerar o link — nunca por parâmetro de query na URL do iframe. É por segurança: quem vê a URL do iframe não consegue repintar o seu botão, e toda página estilizada fica atribuível a um tenant.
O id do número placeholder não vem no corpo da resposta — ele só existe na claim numberId do JWT. Decodifique o payload em base64url (é leitura de metadado; não é preciso verificar a assinatura) para lê-lo agora e já cadastrar o webhook (passo 4) antes de o cliente concluir. Você também recebe esse numberId no evento connect:paired, no passo 3. Esses dois são os caminhos suportados — guarde o id assim que decodificar, junto do seu cadastro de cliente, porque é ele que você vai usar depois para apagar um placeholder abandonado.
Gerar o link já consome um slot do plano. A chamada cria um WhatsAppNumber placeholder na hora. Um link abandonado vaza o slot — não existe rotina de limpeza automática. Gere o link só depois que o cliente clicar em conectar, e devolva o slot quando a sessão terminar sem um connect:paired (passo 3) — o DELETE está em Limpe placeholders órfãos.É aqui, e não no complete, que o slot é cobrado. Sem slot disponível a chamada responde 402 e nenhum link e nenhum placeholder são criados. Leia o code, porque só um dos dois é sobre dinheiro: PLAN_NUMBER_LIMIT_REACHED significa que a cota do próprio plano está cheia e nenhum extra foi comprado — libere um slot em /numbers ou suba de plano, já que crédito não resolve; INSUFFICIENT_FUNDS significa que o número seria um extra pago que não dá para custear — adicione créditos ou salve um cartão. O corpo traz plan, maxNumbers e currentNumberCount para você saber dizer ao seu cliente qual é o caso. Veja erros de capacidade.
O token é o último segmento da remotePairingUrl (/connect/<token>). Ele é um segredo bearer de 30 minutos (HS256, TTL de 30 min), não é de uso único. No modelo hospedado ele viaja na URL do iframe — isso é inerente a embutir a página da Pilot: quem tiver o link pode concluir o fluxo enquanto ele valer. Gere o link na hora em que o cliente vai clicar, não antes.
2

Embeda o iframe hospedado

Com o connectUrl em mãos, o frontend monta a URL do iframe. Você anexa três parâmetros: embed=1, mode=button e parentOrigin — este último é location.origin, que só o browser sabe.
Frontend — HTML + JS
O SDK do Facebook e o popup do Meta Embedded Signup vivem dentro desse iframe hospedado — seu app não carrega SDK nenhum. O botão renderiza a partir do branding.button que você mandou no passo 1.
O sandbox precisa de allow-popups e allow-popups-to-escape-sandbox. A janela de login do Facebook abre de dentro do frame; sem esses dois, o navegador bloqueia o popup e não acontece nada — sem erro, sem callback, sem nada no console. Copie o sandbox exatamente como está acima.
No modo botão a página renderiza só o botão, e o fundo dela fica transparente — isso é propriedade do próprio mode=button, não de estar num iframe —, então o fundo do seu app aparece atrás. Já o pequeno marcador “secured by pilotstatus.com.br” é o que depende do iframe: dentro de um iframe de verdade ele fica escondido por padrão (você não precisa pedir). Aberto em nível superior (o mesmo link sem iframe em volta), o marcador aparece, de propósito: um botão de login estilizado sozinho numa URL *.pilotstatus.com.br, sem procedência visível, é exatamente o que o Safe Browsing marca como enganoso.
3

Escute o evento connect:paired

O iframe posta o resultado para a sua página por postMessage. Registre um listener de message e valide a origemevent.origin === "https://connect.pilotstatus.com.br" — antes de confiar em qualquer coisa.
Frontend — HTML + JS
O payload de connect:paired traz numberId, phone, displayName, provider, externalRef e redirectUrl — todos sempre presentes (um fluxo que não conhece algum envia null, nunca omite). Cruze pelo externalRef para casar o número com o cadastro de cliente do seu lado. O numberId é o id usado em todo o resto da API (GET /v1/numbers/{id}, webhooks, envio). Remova o listener no cleanup da sua página/componente.

connect:error também é o seu sinal de “o cliente desistiu”

O connect:error chega numa falha terminal e quando a pessoa cancela ou fecha o popup do Facebook — o Facebook não distingue as duas coisas, então ambas chegam pelo mesmo evento, com o mesmo payload { message }. Nos dois casos o botão continua na tela, e a pessoa pode simplesmente clicar de novo.Isso importa por causa do custo: o número placeholder criado no passo 1 já está segurando um slot do plano. O link expira em 30 minutos, mas o placeholder não some junto — ele fica lá segurando o slot até alguém apagar. Em vez de deixar isso acontecer, rode uma limpeza delete-on-abandon sempre que a sessão terminar sem um connect:paired — você fechou o modal, a pessoa saiu da página, ou você desistiu da sessão — e não no primeiro connect:error, que ela ainda pode tentar de novo. O browser avisa o seu backend (ele não tem chave ps_), e o seu backend apaga o placeholder com DELETE /v1/numbers/{id}, devolvendo o slot na hora. A chamada está em Limpe placeholders órfãos; o id é o numberId que você leu da claim do JWT no passo 1.
Backend — devolve o slot ao abandonar
Não delete no primeiro connect:error. O botão continua ali para uma nova tentativa — e apagar o placeholder não invalida o link: o token é verificado só pela assinatura e pela validade, sem consultar o banco. Se a pessoa clicar de novo depois do seu DELETE, o fluxo conclui assim mesmo e a Pilot Status cria um número novo, consumindo um slot novo (o limite do plano é reavaliado nessa hora). Ou seja, deletar cedo demais não te protege: te dá um segundo número inesperado. Dispare a limpeza só quando a pessoa realmente foi embora: fechou o seu modal, saiu da página, ou você desistiu da sessão.
4

Cadastre o webhook

Um número conectado por este fluxo é um número META. Inscreva-o para receber as mensagens que chegarem. Use o numberId que você leu da claim do JWT (passo 1) ou recebeu no connect:paired (passo 3):
cURL
O events é obrigatório e precisa ser explícito: uma lista vazia não entrega nada. Use ["*"] para receber tudo (incluindo os eventos de saúde do número, que só chegam pelo curinga). Como o numberId já é o id definitivo desde o passo 1, você pode cadastrar o webhook antes de o cliente concluir — assim não perde as primeiras mensagens. As entregas usam BullMQ com 5 tentativas e backoff exponencial a partir de 5 segundos.
Número META entrega o envelope nativo da Meta, com entry[].changes[].field: "messages" — e nunca message.received. O evento message.received é vocabulário de números não oficiais (Pilot Status web). Inscrever-se em events: ["*"] não converte o envelope: você continua recebendo o formato nativo. Escreva o parser do seu receptor para o formato nativo (ou ramifique pelo provedor do número).Todo nome de campo que você pode assinar num número Meta está listado em Eventos da Meta Cloud APImessages é o que você quer para conversas.
Envelope nativo (recorte)
Hoje nenhum cabeçalho de assinatura é emitido para webhooks criados pela API pública: o campo secret nunca é gravado, então x-pilot-status-signature não chega. Não escreva verificação de assinatura contando com ele. Autentique as entregas por outro meio — URL secreta e imprevisível, mTLS ou allowlist de IP.

Backend de exemplo

O seu backend só precisa de um endpoint: ele gera o link e devolve o connectUrl (e, opcionalmente, o numberId decodificado). Nenhuma rota de config, nenhuma rota de complete — o complete acontece dentro do iframe hospedado.
Backend — Node.js / Express
A chave ps_ é um segredo de servidor. Nunca a coloque numa variável exposta ao browser (VITE_..., NEXT_PUBLIC_...) nem a devolva num JSON que o frontend possa ler. O frontend recebe apenas o connectUrl.

Limpe placeholders órfãos

Cada link gerado cria um número placeholder que ocupa um slot. Se o cliente abandonar o fluxo, o placeholder fica órfão e o slot fica preso. Remova-o pelo painel /numbers ou pela API:
cURL
Responde 200 { "ok": true } (ou 404 "Number not found" se o id não existir). Aceita o id do número ou o id da instância. Use o numberId que você guardou no passo 1 (a claim numberId do JWT, ou o mesmo id que chega no connect:paired) para liberar o slot de um placeholder abandonado — de preferência automatizando quando a sessão do passo 3 acabar sem connect:paired. Esperar o link expirar não resolve: o placeholder continua ocupando o slot depois dos 30 minutos.
Apagar o placeholder não revoga o link. O token é validado só pela assinatura e pela validade — não há consulta ao banco —, então, enquanto ele valer, uma nova tentativa do cliente ainda conclui e cria um número novo, consumindo outro slot. Delete só quando tiver certeza de que ninguém vai tentar de novo.

Outros caminhos

O botão hospedado é a recomendação — mantém seu app sem SDK do Facebook e sem app Meta próprio. Ainda assim, existem alternativas conforme o seu caso:
  • SDK de incorporação, callbacks JS. Se você prefere um SDK com callbacks (onPaired, onError, onExpired) em vez de um listener de postMessage manual, a referência completa do embed — incluindo PilotStatus.connect.mount(...) no modo botão — está em Incorpore a Página de Conexão.
  • Construtores de IA. Se você usa v0, Lovable, Cursor ou Bolt, há prompts prontos que já geram este mesmo fluxo hospedado em Embedded Signup para construtores de IA.
  • Cliente que já tem WABA própria. Se o cliente já vai colar as credenciais da Cloud API dele na mão, o popup do Facebook não entra na história — use o fluxo credentials (metaFlow: "credentials"), documentado na referência do embed, ou cadastre o número direto com POST /v1/numbers/meta — veja Cadastrar número.

Próximos passos