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, no placeholder, no slot consumed. 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 already consumes a plan slot. The call creates a placeholder WhatsAppNumber right away. An abandoned link leaks the slot — there is no automatic cleanup routine. Generate the link only after the customer clicks connect, wire the delete-on-abandon cleanup shown in step 3, and remove any leftover placeholders yourself in the /numbers panel. This is where the slot is charged — not at complete.With no slot available the call answers 402 and no link and no placeholder are created. Read the code, because only one of the two is about money: PLAN_NUMBER_LIMIT_REACHED means your plan’s own allowance is full and you never bought an extra — free a slot in /numbers or move up a plan, since credits will not clear it; INSUFFICIENT_FUNDS means the number would be a paid extra you cannot fund — add credits or save a card. 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 plan slot

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 cost: the placeholder number created in step 1 is already holding a plan slot, and nothing frees it on its own. 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}, returning the slot right away instead of leaving it parked.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 and consuming a fresh plan slot (the plan limit is re-checked at that moment). 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 eventsmessages 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 number keeps holding its plan slot. Remove it with 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 frees the slot, but it 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 and a new slot consumed. 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.