> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pilotstatus.com.br/llms.txt
> Use this file to discover all available pages before exploring further.

# Meta Embedded Signup in your app | Pilot Status

> Connect your customer's official WhatsApp Cloud API number by embedding Pilot Status's hosted Connect button in an iframe — no Facebook SDK, no Meta app of your own, and no config to serve.

# 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.

<Note>
  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**](https://pilotstatus.com.br/downloads/embedded-signup-demo.zip). Unzip, set your `ps_` key, `npm run dev` (or `docker compose up`).
</Note>

<Info>
  **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**](/integrations/embed-connect), 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.
</Info>

## Architecture

```text theme={null}
Browser (your page)
   │ 1. POST /api/onboarding/start  → { connectUrl }   (your backend generated the link)
   │ 2. <iframe src="connect.pilotstatus.com.br/connect/<token>?embed=1&mode=button">
   │       ↑ the Facebook SDK and the popup live HERE, inside the hosted page
   │ 3. window "message" → connect:paired → { numberId, externalRef, ... }
   ▼
Your backend ── x-api-key: ps_ ──►  https://pilotstatus.com.br/v1/numbers/remote-pairing
```

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.

<Note>
  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.
</Note>

Keep the `ps_` key on your server. The browser never sees it — it only embeds the iframe.

## The flow

<Steps>
  <Step title="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.

    ```bash cURL theme={null}
    curl -X POST "https://pilotstatus.com.br/v1/numbers/remote-pairing" \
      -H "Content-Type: application/json" \
      -H "x-api-key: ps_your_tenant_key" \
      -d '{
        "provider": "META",
        "metaFlow": "embedded",
        "name": "Acme Corp",
        "externalRef": "crm-customer-4211",
        "branding": {
          "button": {
            "variant": "whatsapp",
            "label": "Connect my WhatsApp",
            "icon": "whatsapp",
            "radius": "pill",
            "size": "lg",
            "height": 56,
            "fullWidth": true
          }
        }
      }'
    ```

    The response is **201** with exactly three fields:

    ```json theme={null}
    {
      "provider": "META",
      "remotePairingUrl": "https://connect.pilotstatus.com.br/connect/eyJhbGciOi...",
      "expiresAt": "2026-07-20T18:30:00.000Z"
    }
    ```

    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.

    <Tip>
      **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.
    </Tip>

    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](/integrations/embed-connect#button-styling-token-only). 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.

    <Note>
      `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.
    </Note>

    **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.

    ```js Your backend theme={null}
    // token = the last path segment of remotePairingUrl
    const numberId = JSON.parse(
      Buffer.from(token.split(".")[1], "base64url").toString("utf8")
    ).numberId;
    ```

    <Note>
      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.
    </Note>

    <Warning>
      **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](/api/numbers/create#capacity-errors-and-what-fixes-them).
    </Warning>
  </Step>

  <Step title="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.

    | Parameter      | Why it's there                                                                                                                                                                                                                                                                                |
    | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `embed=1`      | **Required.** Turns on the `postMessage` channel to your page. Leave it out and the button works but you never receive `connect:paired`.                                                                                                                                                      |
    | `parentOrigin` | **Required with `embed=1`.** The only origin the iframe will post to. Must be your page's exact `location.origin`.                                                                                                                                                                            |
    | `mode=button`  | **Optional here**, because the link already carries `branding.button` from step 1. Keep it anyway — it is one word that makes the shape explicit and keeps working if you later mint a link *without* custom styling. Its opposite, `mode=page`, forces the full card even for a styled link. |

    ```html theme={null}
    <div id="wa-connect"></div>
    <script>
      // connectUrl is the remotePairingUrl your backend returned in step 1.
      const connectUrl = "https://connect.pilotstatus.com.br/connect/eyJhbGciOi...";

      const iframe = document.createElement("iframe");
      iframe.src =
        connectUrl +
        "?embed=1&mode=button&parentOrigin=" +
        encodeURIComponent(location.origin);
      iframe.style.width = "100%";
      iframe.style.border = "0";
      iframe.style.height = "60px"; // grows via the resize message in step 3
      iframe.setAttribute(
        "sandbox",
        "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
      );
      document.getElementById("wa-connect").appendChild(iframe);
    </script>
    ```

    <Note>
      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.
    </Note>

    <Note>
      **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.
    </Note>

    <Warning>
      **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.
    </Warning>
  </Step>

  <Step title="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`.

    ```html theme={null}
    <script>
      const CONNECT_ORIGIN = "https://connect.pilotstatus.com.br";

      window.addEventListener("message", (event) => {
        // Two mandatory checks. The origin must be the connect host, and every
        // message carries the marker `source: "pilot-status-embed"` — without the
        // second check any script on your own page could forge a connect:paired.
        if (event.origin !== CONNECT_ORIGIN) return;
        const msg = event.data;
        if (!msg || msg.source !== "pilot-status-embed") return;

        // Envelope: { source, v, type, payload }. Only `type` is top-level;
        // everything else lives in `payload`.
        const p = msg.payload || {};
        switch (msg.type) {
          case "connect:paired": {
            const { numberId, phone, displayName, provider, externalRef, redirectUrl } = p;
            // Join externalRef against your own customer record, then persist numberId.
            console.log("Connected", numberId, "for", externalRef);
            break;
          }
          case "connect:expired":
            // The 30-minute link lapsed. Generate a fresh link and re-embed.
            break;
          case "connect:error":
            // Also fires when the customer cancels or closes the Facebook popup.
            // The button stays on screen, so let them retry before cleaning up.
            console.error("Connect failed or was cancelled:", p.message);
            break;
          case "resize":
            iframe.style.height = p.height + "px";
            break;
        }
      });
    </script>
    ```

    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.

    ```js Browser theme={null}
    case "connect:paired":
      connected = true; // a real number now — never delete it.
      break;

    case "connect:error":
      // Cancelling is not leaving: the button is still there for a retry.
      console.error("Connect failed or was cancelled:", p.message);
      break;

    // ...elsewhere, when the customer is actually gone — you closed the modal,
    // they navigated away, or you gave up on the session:
    if (!connected) {
      // Your backend holds the ps_ key and calls DELETE /v1/numbers/{id}.
      await fetch("/api/onboarding/abandon", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        // numberId: decoded from the token in step 1 and kept with the customer.
        body: JSON.stringify({ numberId, externalRef: "crm-customer-4211" }),
      });
    }
    ```

    ```js Your backend theme={null}
    // POST /api/onboarding/abandon { numberId } — the ps_ key never leaves here.
    const r = await fetch(`https://pilotstatus.com.br/v1/numbers/${numberId}`, {
      method: "DELETE",
      headers: { "x-api-key": process.env.PILOT_TENANT_KEY },
    });
    // 200 { ok: true } — slot returned. 404 — already gone, nothing to do.
    ```

    See [Clean up an abandoned link](#clean-up-an-abandoned-link) below for the delete call in full.

    <Warning>
      **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.
    </Warning>
  </Step>

  <Step title="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](/api/webhooks/configure) for the full CRUD surface.

    ```bash cURL theme={null}
    curl -X POST "https://pilotstatus.com.br/v1/webhooks" \
      -H "Content-Type: application/json" \
      -H "x-api-key: ps_your_tenant_key" \
      -d '{
        "url": "https://your-crm.example.com/hooks/pilot",
        "whatsappNumberId": "wn_9f3c21ab",
        "events": ["*"]
      }'
    ```

    <Warning>
      **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.
    </Warning>

    <Warning>
      **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](/api/webhooks/events#meta-cloud-api-events-native-envelope) — `messages` is the one you want for conversations.
    </Warning>

    <Warning>
      **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.
    </Warning>

    Delivery is queued and retried **5 times** with exponential backoff starting at 5 seconds. Return `2xx` quickly and process asynchronously.
  </Step>
</Steps>

## Clean up an abandoned link

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.

```bash cURL theme={null}
curl -X DELETE "https://pilotstatus.com.br/v1/numbers/wn_9f3c21ab" \
  -H "x-api-key: ps_your_tenant_key"
```

Success is **200**:

```json theme={null}
{ "ok": true }
```

An id that does not exist returns `404` with `"Number not found"`. You can also delete orphaned placeholders from the dashboard at `/numbers`.

<Note>
  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.
</Note>

## 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.

<CardGroup cols={2}>
  <Card title="AI-builder prompts" icon="wand-magic-sparkles" href="/guides/embedded-signup-ai-builders">
    Copy-paste prompts that scaffold this exact hosted-button flow.
  </Card>

  <Card title="Embed Connect reference" icon="window" href="/integrations/embed-connect">
    The full embed surface: page mode vs. button mode, the postMessage protocol, and branding.
  </Card>

  <Card title="Remote pairing reference" icon="link" href="/api/numbers/remote-pairing">
    Full body and options for the link-generating endpoint.
  </Card>

  <Card title="Configure webhooks" icon="webhook" href="/api/webhooks/configure">
    Per-number subscriptions, event lists, and delivery logs.
  </Card>
</CardGroup>
