Skip to main content
Embed the WhatsApp number-pairing flow (Connect) — QR code (unofficial) or Meta Embedded Signup (official) — in your own SaaS, white-label. Shared prerequisites (SDK, tenant key) are in Embed the Chat Inbox.
Hosted connect page with brandable logo, colors, title, and a Connect Device button

The hosted connect page your customer sees — brandable logo, colors, and title, with a single Connect Device button.

This page is the reference — every option, both providers, the full postMessage protocol, and the complete branding.button table. If you are wiring the official Cloud API onboarding for the first time, start with the step-by-step instead: Embedded Signup in your app walks the four calls end to end, with copy-paste backend and frontend code. Come back here for the field-level detail.
Key difference vs. Chat: Connect does not use the POST /v1/embed/sessions token. It uses the remote-pairing token minted by POST /v1/numbers/remote-pairing, and that token lives in the iframe URL path (/connect/<token>), not in memory via postMessage.

Token shape and TTL

The token you get back depends on provider — they are not the same object and they do not share a TTL: Mint the Meta link when the user is about to click, not hours ahead — 30 minutes is short on purpose. When it lapses the iframe emits connect:expired; mint a new one.

Flow

1

Backend creates the pairing link

201 in both cases. remotePairingUrl is https://connect.pilotstatus.com.br/connect/<token>; the token is the last path segment. Forward only the token (or the URL) to your frontend — never the ps_ key.For provider: "META", name/number are not required: the end-user picks the number inside the Facebook dialog. For PILOT_STATUS, both are required.warnings on a QR pairing. externalRef and redirectUrl ride the Meta signed session, and a QR link has none — so on PILOT_STATUS they are accepted but cannot be honored. Each one you send is reported back in a warnings array (string[]) on the 201, and the pairing still succeeds; it is not a 400. The field is absent when there is nothing to report. Read it in your integration tests — it is how you find out a value you passed was ignored instead of applied.
linkToApiKey is retired — and linkedApiKeyId is always null. The option is gone from all three provisioning routes (POST /v1/numbers, POST /v1/numbers/meta, POST /v1/numbers/remote-pairing). Sending it is still accepted, so a caller that has it in its payload today does not break, but it does nothing: no API key is touched, and linkedApiKeyId on the 201 never carries an id. It used to re-point a number-scoped key at the number that had just been created, silently unscoping it from the number it was actually bound to.
The per-link branding field is branding (not brandingOverride). externalRef (max 200 chars) is your own customer id — it is signed into the Meta token and echoed back to you on connect:paired.
2

Frontend embeds it

The SDK builds the iframe URL from the token — you pass only the token:
ConnectOptions: token (required), baseUrl? (default https://connect.pilotstatus.com.br), mode? ("page" | "button" — omit for auto, see Button mode), onPaired(d), onError(e), onExpired().

Button mode

Button mode renders only the Facebook Embedded Signup button — no header, no card, no timeline, transparent background, height driven by the existing resize message. Everything else (token, postMessage protocol, callbacks) is identical to page mode. Use it when you already have your own onboarding screen and the hosted card would be a second, competing UI inside it. Use page mode when you want the full explanatory flow, or for QR pairing. A link minted with branding.button renders the button by default. Sending that object is the request for a button — there is no second flag to remember, and no way to style a button and then be surprised by a card. Three inputs decide it, in precedence order:
This changed on 2026-08-17. A link minted with branding.button used to render the full page unless the host also appended ?mode=button. If you mint with branding.button and deliberately want the full page — the token’s styling then applies to the button inside the card — append ?mode=page (or pass mode: "page"). Nothing else changes: an unbranded link still renders the page, and an explicit ?mode=button still renders the button.
branding.button can only reach a link through the signed token, so auto mode can only fire for a link whose own backend asked for it: there is no tenant-level setting that could flip your other links, and no query parameter that could flip someone else’s. Button mode also clears the page’s own background (html and body). That is a property of button mode itself, not of being framed — it holds however the URL is loaded. In the normal embed the effect is that your host page shows through the iframe and the button sits on your surface rather than on a stray solid rectangle. Nothing to configure on your side.
Button mode requires a Meta token minted with metaFlow: "embedded". A metaFlow: "credentials" token ignores button mode — asked for explicitly or reached through auto — and falls back to the full page: the Cloud API credentials form does not fit in a button. QR (PILOT_STATUS) tokens likewise render the full page. Loading, config-error, expired-token and success states still appear in button mode, inline and minimal — never full-screen.
The iframe must be sandboxed with allow-popups and allow-popups-to-escape-sandbox. The Facebook login window opens from inside the frame; without allow-popups the browser blocks it and nothing happens — no error, no callback, no console message. The SDK sets the correct sandbox for you (allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox); if you build the iframe by hand, copy it exactly.
Facebook validates the origin of the iframe, not of your top-level page. That is the point of this integration: the button runs on connect.pilotstatus.com.br, a domain already registered with Pilot Status’ Meta app, so you never register your own (or your customer’s) domain.

Button styling (token only)

Button appearance is set when you mint the link, inside branding.button, and is carried in the signed token. Sending it also puts the link in button mode by default, so this one object is the whole request: style a button, and get a button.
That mints a link whose page is nothing but a green WhatsApp button with its own logo, 56 px tall and 320 px wide. Open the returned remotePairingUrl in a browser to see exactly what your customer will see — the only difference is the “secured by” marker, which shows top-level and hides inside your iframe. Every field is optional. The built-in label is the Portuguese string Continuar com o Facebook — set label explicitly for any other locale. Keep the object small: it rides inside the JWT that sits in the connect URL.
  • variant: "whatsapp" paints the button WhatsApp green (#25D366).
  • icon is an inline brand glyph (SVG, no network call) drawn before the label; it inherits the text color. Omit it and it follows the variant: facebook → the Facebook glyph, whatsapp → the WhatsApp glyph, any other variant → no glyph at all. Send icon: "none" to drop it explicitly.
  • height is applied as a min-height — the label never clips — and replaces the vertical padding that size would have given the button.
  • width overrides fullWidth: when width is present, fullWidth is ignored. Without width, the button fills its container by default; set fullWidth: false to shrink it to its content.
  • Validate before you call. Every bound in the table is enforced when you mint the link: a value outside the allowed range or enum makes POST /v1/numbers/remote-pairing answer 400 Validation error with a details breakdown, and nothing is created — no token, no link, no placeholder. A height of 1000 is a rejected request, not a button silently clamped back to the height implied by size.
The connect page itself does fall back to the default for any button field it cannot make sense of, but that is a renderer safety net for a stale value already sitting inside a signed token. It is not reachable from the API: a bad value sent to POST /v1/numbers/remote-pairing gets you a 400, never a defaulted button.
None of these can be set by query parameter, and there is no plan to allow it. Styling arrives only through a token minted with your ps_ key, so every styled page is attributable to a tenant. A page whose appearance any anonymous visitor could rewrite via the query string is exactly the phishing surface that got a Pilot Status domain flagged once — the query-string overlay stays limited to the pre-existing cosmetic params below.

The “secured by pilotstatus.com.br” marker

Whether the small provenance marker renders under the button depends on where the page is running — you do not have to configure anything for the normal embedded case. There are exactly three cases:
  • Framed button mode hides it automatically. The page checks whether it is actually inside a frame (window.parent !== window); if it is, the marker is dropped. No token field, no switch, nothing to ask for — embed the button and it is already gone.
  • Top-level button mode keeps it, deliberately. A lone, tenant-styled login button sitting on a *.pilotstatus.com.br URL with nothing identifying whose page it is, is precisely the surface Safe Browsing flags as deceptive. The marker is what keeps that URL attributable, so it stays whenever the button is not embedded.
  • The full page always keeps it, including when hidePilotStatusBranding is set.
branding.button.hideProvenance: true still exists as an explicit opt-out for the top-level button case, and it is still paired with a server-side switch that Pilot Status controls. That switch is off by default, so setting hideProvenance on its own changes nothing; ask support if you genuinely need it. It cannot be set by query parameter, only in the minted token — and it never affects the full connect page.

postMessage protocol (iframe → parent)

Connect never receives an init message — the token is already in the URL. The SDK only listens (validating origin === connect.pilotstatus.com.br and source === iframe.contentWindow):

Plan slots and the delete-on-abandon pattern

Minting the link already costs a plan slot. POST /v1/numbers/remote-pairing creates a placeholder number immediately, and an abandoned link keeps holding that slot — there is no automatic cleanup. Mint the link only when the customer is about to click.When there is no slot for one more number, the call answers 402 — the same body POST /v1/numbers returns — and no link and no placeholder are created. Two codes share that status and they do not share a remedy: PLAN_NUMBER_LIMIT_REACHED (the plan’s own allowance is full and no extra was ever bought — free a slot or move up a plan; money changes nothing) and INSUFFICIENT_FUNDS (the number is a paid extra with no credits and no saved card). Branch on code, and read plan / maxNumbers / currentNumberCount from the same body. This path used to answer 500 for it, and used to report a full plan allowance as INSUFFICIENT_FUNDS. See capacity errors.
Give the slot back yourself instead of waiting for the link to lapse. connect:error is the signal that an attempt failed — including someone closing the Facebook popup — but it is not the signal to delete: the button is still on screen and they can click again. Note the failed attempt, and run the cleanup when the session is really over without a connect:paired (you closed the modal, they navigated away, you gave up):
When you do give up, your backend deletes the placeholder it created for that customer with DELETE /v1/numbers/{id} and the ps_ key — the slot comes back immediately instead of staying held until you notice. The cleanup call itself is written out in Embedded Signup. Where the placeholder id comes from. There are exactly two supported sources, and neither is a listing call:
  • The numberId claim inside the Meta token. Split the JWT on . and base64url-decode the middle segment — a metadata read of a token you minted yourself, no signature check needed.
  • The numberId field on connect:paired — but that only arrives on success, so it is no help for the abandoned case.
Decode the claim when you generate the link and store it next to your own customer record. Do not plan on rediscovering it later from GET /v1/numbers.
Deleting the placeholder does not invalidate the link. The pairing token is verified statelessly — signature, kind and expiry only, with no database lookup — so GET /v1/meta-embedded/{token} keeps answering valid: true after the placeholder row is gone, and the customer’s button keeps working until the 30 minutes lapse. Completion then finds no placeholder and falls back to creating a brand-new number, re-asserting your plan limit at that moment. So delete only once you are confident the customer is not retrying — not because the link stops working, but because a retry after your delete hands you a second, unexpected number holding a fresh slot.

connect:paired payload

  • numberId is the id you use everywhere else in the API (GET /v1/numbers/{id}, webhooks, sending).
  • externalRef is whatever you passed when minting — join it against your own customer record. It is carried by Meta links; on QR pairing it arrives as null.
  • All six fields — numberId, phone, displayName, provider, externalRef, redirectUrl — are always present: a flow that cannot know one sends null, never omits it, so you do not need per-path branching over them.
  • redirectUrl, when present, is not followed while embedded — the iframe hands it to you and lets your page decide whether and where to navigate, instead of blowing out of the frame.

White-label / branding

Precedence: URL query params > per-link branding (from the minted token, or snapshotted on the number for QR links) > tenant branding (GET/PUT /v1/branding) > Pilot Status defaults. Query params are cosmetic-only and deliberately narrow — primary, bg, logo, title, subtitle, support, hideBranding. They are applied only after the token resolves as valid; on an invalid or expired token the page falls back to Pilot Status defaults and ignores them entirely. ?logo must be https and is restricted to the first-party asset host. Button styling and hideProvenance are not part of this set.

Security model

  • x-api-key stays on your backend; only it calls POST /v1/numbers/remote-pairing.
  • The pairing token authorizes only that link’s pairing endpoints, and expires — 30 minutes for a Meta token, 24 hours for a QR pairing token.
  • Origins are validated on both sides; the iframe runs on connect.* so its API calls are same-origin (no CORS surface for your app).
  • Anything that changes how the page looks to an end-user is authenticated: it comes from a token minted with your key, never from a URL a stranger can type.