Skip to main content

Embedded Signup in your app

The recommended way to run Meta Embedded Signup inside your product is to embed Pilot Status’s hosted Connect button in an iframe. Your customer clicks Continue with Facebook, completes Meta’s popup, and their official WhatsApp Cloud API number lands in your Pilot Status account. The Facebook SDK and the login popup live inside the hosted page on connect.pilotstatus.com.br. Your app loads no Facebook SDK, runs no Facebook login code, and never touches Meta’s APIs — none of the Meta wiring is yours to set up. It does exactly two things:
  1. Your backend generates the connection link (POST /v1/numbers/remote-pairing with your tenant ps_ key).
  2. Your page embeds the returned link in an iframe and listens for the connect:paired message.
That’s the whole integration. No Meta app of your own, no App Review, no Meta credentials to serve, no domain to register.
Prefer a running start? Download the MIT-licensed demo — a Node backend and a React frontend wired to exactly this flow, with ready-made button presets: embedded-signup-demo.zip. Unzip, set your ps_ key, npm run dev (or docker compose up).
This page is the step-by-step: follow the four steps in order and you have a working integration. Its sibling, Embed the Connect Page, is the reference — every option, both providers (Cloud API and QR), the full postMessage protocol and the complete branding.button field table. This page links there instead of restating it, so you always read one copy.

Architecture

Only your backend ever calls the Pilot Status API, and it does so for one reason: to generate the link. The browser only embeds the iframe — it never calls the Pilot Status API, so there is no CORS surface for your app to worry about.

What you need

One credential: a tenant-scoped API key. Copy it from the dashboard at /profile, under the API tab. It starts with ps_. It must be tenant-scoped, not number-scoped: generating a link creates a brand-new placeholder number, so a key bound to one existing number cannot authorize it.
There is nothing else to provision. You do not register a Meta app, submit App Review, or create a number-scoped key — there is no Meta app wiring of any kind on your side. Everything the popup needs already lives on the hosted page. If your key was issued through OAuth with per-number consent grants, the route returns 403 NUMBERS_GRANT_NOT_ALLOWED — use a tenant key instead.
Keep the ps_ key on your server. The browser never sees it — it only embeds the iframe.

The flow

1

Your backend generates the connection link

Call POST /v1/numbers/remote-pairing with provider: "META" and metaFlow: "embedded", using your tenant key. Pass branding.button to style the button that renders inside the iframe.
cURL
The response is 201 with exactly three fields:
Hand remotePairingUrl down to the page you’ll embed it on — that is the connectUrl in the diagram above. externalRef (your own customer id) is signed into the link and echoed back to you on connect:paired, so you can join the result to your records.
Check it in five seconds: paste remotePairingUrl into a browser. Because you sent branding.button, that link renders nothing but your styled button — the 56 px green WhatsApp button in the example above. That is exactly what your customer sees inside the iframe in step 2. (Top-level you also get the small “secured by pilotstatus.com.br” marker; inside a real iframe it hides itself.)Seeing a full card with a header and explanatory text instead? Then the link carries no branding.button — check that you nested it under branding, not at the top level of the body.
This is the part worth being precise about, because the two halves live in different places:
  • This call decides the style and the shape. branding.button is signed into the token, and sending it is also what makes the link render the bare button instead of the full page. One object, both effects.
  • The ?embed=1&parentOrigin=… parameters in step 2 decide the plumbing. They are what turn on the postMessage channel back to your page. Without them the button still renders and still works — you just never hear about the result.
What branding.button accepts. All ten fields are optional, and the full table — values, defaults, ranges, plus hideProvenance — is in the reference: Button styling. The three that trip people up:
  • icon follows variant when you omit it. facebook → Facebook glyph, whatsapp → WhatsApp glyph, any other variant → no glyph at all. Pass "none" to drop it deliberately.
  • width overrides fullWidth. Send a width and fullWidth is ignored, whatever you set it to.
  • A bad value is rejected, not corrected. Out-of-range or off-enum gets you 400 Validation error with a details object — and no link and no placeholder. height: 1000 does not fall back to the default; it fails the whole call.
branding.button can only be set here, when you mint the link — never through a query param on the iframe URL. That is deliberate: a stranger who sees the iframe URL cannot restyle your button, and every styled page stays attributable to a tenant.
The placeholder’s id is not in the response body. It lives in the numberId claim of the token — the last path segment of remotePairingUrl. Decode the base64url payload to read it (a metadata read; no signature check needed) and store it next to your customer record, because that id is what you need for the delete-on-abandon cleanup in step 3, and it lets you register the webhook (step 4) before the customer finishes. The same id arrives again on connect:paired. Those two — the decoded claim and the event — are the supported ways to obtain it.
Your backend
The link carries a signed token with a 30-minute TTL. In the hosted model this token rides in the iframe URL — that is inherent to embedding a hosted page, so generate the link just before the customer clicks connect, not hours ahead.
Generating the link takes no plan slot and charges nothing. The call creates a placeholder WhatsAppNumber right away, but a placeholder is not a number: it does not count toward your capacity and it is not billed. The number starts counting — and, when it is past the capacity you have paid for, its extra number is charged — at its activation: when the signup completes and the number becomes active with us. That is a Meta number’s first connection: see Charged at the first connection. If that charge is refused, the number stays active but sending from it is blocked until it is paid.An abandoned placeholder stays in your number list as “awaiting connection” until it is removed automatically, 24 hours after it was created. To keep the list clean, generate the link only after the customer clicks connect, and wire the delete-on-abandon cleanup shown in step 3.One refusal is left at this step: the call answers 402 and no link and no placeholder are created. Read the code together with your plan. On the Free plan a number past the plan’s allowance always answers PLAN_NUMBER_LIMIT_REACHED, and credits will not clear it, because Free never buys an extra number by itself — free a slot in /numbers, buy an extra number yourself or move up a plan. On a paid plan the refusal only happens when the number would be an extra one the workspace cannot fund — the wallet balance does not cover the prorated price and there is no saved card: PLAN_NUMBER_LIMIT_REACHED when your plan’s own allowance is full and you never bought an extra, INSUFFICIENT_FUNDS when it is one more paid extra. Add credits or save a card and both clear. The body carries plan, maxNumbers and currentNumberCount so you can tell your customer which it is. See capacity errors.
2

Embed the hosted button in an iframe

Append ?embed=1&mode=button&parentOrigin=<your page origin> to the link and drop it in an iframe. parentOrigin is your page’s location.origin — only the browser knows it, so build the src in the browser.
The Facebook SDK and the login popup run inside this iframe, on connect.pilotstatus.com.br — a domain already registered with Pilot Status’s Meta app. Your app loads no Facebook SDK, runs no Facebook login code, and registers no domain of its own: none of the Meta app wiring is yours to provision.
Button mode renders only the button, and clears the page background — that is a property of mode=button itself, so whatever sits behind the frame shows through. You don’t have to ask for it.The small “secured by pilotstatus.com.br” marker is a separate, independent rule: it is hidden by default when the page really is inside an iframe, and it does show when the same link is opened top-level, on purpose — a stand-alone styled login button on a *.pilotstatus.com.br URL with no visible provenance is exactly what Safe Browsing flags as deceptive.
The sandbox must include allow-popups and allow-popups-to-escape-sandbox. The Facebook login window opens from inside the frame; without both flags the browser blocks it and nothing happens — no error, no callback, no console message. Copy the full sandbox value above exactly.
3

Listen for connect:paired

The iframe posts messages to your page. Validate event.origin on every one — it must equal https://connect.pilotstatus.com.br.
On connect:paired you receive { numberId, phone, displayName, provider, externalRef, redirectUrl }. Every field is always present — a flow that cannot know one sends null rather than omitting it, so you never need per-path branching.
  • numberId is the id you use everywhere else in the API (GET /v1/numbers/{id}, webhooks, sending). Persist it.
  • externalRef is whatever you passed when you generated the link — match it against your own customer record.
  • redirectUrl, when set, is handed to you rather than followed — your page decides whether and where to navigate.
Also handle connect:expired (generate a new link), connect:error (surface the message), and resize (adjust the iframe height, which starts small and grows once the button renders).

Cancel, retry, and the placeholder

connect:error arrives on a terminal failure and when the customer cancels or closes the Facebook popup — Facebook does not tell the two apart, so both reach you the same way, with the same { message } payload. The button stays on screen either way, so the person can simply click again.That matters for your number list, not for cost: the placeholder created in step 1 holds no plan slot and is not billed, but it stays listed as “awaiting connection” until it is removed automatically, 24 hours after it was created. So run a delete-on-abandon cleanup whenever the session ends without a connect:paired — you closed the modal, the customer navigated away, or you gave up — and not on the first connect:error, which they can still retry past. The browser tells your backend the session died (the browser has no ps_ key), and your backend deletes the placeholder with DELETE /v1/numbers/{id}, removing it right away instead of leaving it for the automatic cleanup.Where the id comes from: it is the numberId you decoded from the token in step 1 and stored alongside your customer record — that is what your page sends to your own backend here. (If the flow got as far as connect:paired, the same id arrived in that payload.) Hand the browser that id when you hand it the connectUrl, and the cleanup needs no lookup at all.
Browser
Your backend
See Clean up an abandoned link below for the delete call in full.
Don’t delete on the first connect:error. The button is still on screen for a retry, and deleting does not revoke the link: the pairing token is verified statelessly — signature, kind, and expiry only, with no database lookup — so a customer who clicks again after your cleanup still completes the flow. What they get then is a brand-new number, created from scratch: the capacity check runs again at that moment, and the number counts — and is charged, when it is an extra one — at its activation. That is the real cost of deleting too early — not a broken link. Fire the cleanup once you are confident they are gone: they closed your modal, navigated away, or you gave up on the session.
4

Register a webhook for the connected number

With the numberId in hand — decoded from the token in step 1, or received on connect:paired — subscribe your endpoint with POST /v1/webhooks, using the same tenant key. Since the id is already final at link-generation time, you can register the webhook before the customer finishes and not miss the first inbound messages. See Configure webhooks for the full CRUD surface.
cURL
The events list is mandatory. An empty or omitted events list dispatches nothing — there is no implicit “subscribe to all”. Pass an explicit list, or ["*"] for every event.
A META number delivers Meta’s native envelope, not Pilot Status events. Inbound messages arrive with field: "messages" inside entry[].changes[] — exactly as Meta sends them. A META number never emits message.received; that vocabulary belongs to unofficial (Pilot Status web) numbers. Subscribing with events: ["*"] does not translate the payload — it delivers the raw envelope. Parse body.entry[0].changes[0].field and branch on it.Every field name you can subscribe to on a Meta number is listed in Meta Cloud API events — messages is the one you want for conversations.
There is no x-pilot-status-signature header today. Webhooks created through the public API never get a secret written, so nothing is signed. Do not build signature verification and assume it protects you. Authenticate deliveries another way: an unguessable URL path, mTLS, or an IP allowlist.
Delivery is queued and retried 5 times with exponential backoff starting at 5 seconds. Return 2xx quickly and process asynchronously.
If a customer never finishes, the placeholder stays in your number list as “awaiting connection”. It holds no plan slot and is not billed, and it is removed automatically 24 hours after it was created. To remove it sooner, call DELETE /v1/numbers/{id}, using your tenant key. The id is the numberId you decoded from the token when you generated the link (step 1), or the one delivered on connect:paired — a number id or an instance id both work. This is the call behind the delete-on-abandon pattern in step 3.
cURL
Success is 200:
An id that does not exist returns 404 with "Number not found". You can also delete orphaned placeholders from the dashboard at /numbers.
Deleting the placeholder does not invalidate the link: the token is verified on signature, kind, and expiry alone. Until it expires, a customer who retries will still complete — and, with the placeholder gone, a new number is created, which counts and is charged like any other at its activation. So delete once you are confident nobody is coming back, or be ready for a second, unexpected number.

Prefer your own markup?

If you have a fixed domain and want the button to render on your own page, you can run Meta Embedded Signup yourself by loading the Facebook JavaScript SDK and driving FB.login in the browser — but that means registering your own Meta app config, wiring the popup’s two message channels, and owning the CORS setup. For almost everyone, the hosted button in this guide is the recommended path: zero SDK, zero Meta app, zero config.

AI-builder prompts

Copy-paste prompts that scaffold this exact hosted-button flow.

Embed Connect reference

The full embed surface: page mode vs. button mode, the postMessage protocol, and branding.

Remote pairing reference

Full body and options for the link-generating endpoint.

Configure webhooks

Per-number subscriptions, event lists, and delivery logs.