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

# WhatsApp Signup for Lovable, Replit, Bolt, v0 and Cursor | Pilot Status

> Add a working WhatsApp signup button to an app built on Lovable, Replit, Bolt, v0 or Cursor by embedding Pilot Status's hosted button — no Facebook SDK, no Meta app of your own — with four copy-paste prompts for backend, iframe, connect:paired and webhooks.

# WhatsApp signup for AI builders

You are building a CRM or SaaS on **Lovable, Replit, Bolt, v0 or Cursor**, and your customers need to connect their own official WhatsApp number. This page gives you four prompts. Paste them into your builder in order and you get a working signup flow — with **no Facebook SDK on your page, no Meta app of your own, and no `FB.login` in the browser**.

<Note>
  Rather start from working code? Download the MIT-licensed demo of this exact flow (Node backend + React frontend, with ready-made button presets): **[embedded-signup-demo.zip](https://pilotstatus.com.br/downloads/embedded-signup-demo.zip)**. Drop it into your builder, or run it locally with `npm run dev`.
</Note>

## Why the hosted button, and not FB.login on your page

Your builder publishes on a domain it owns — `something.lovable.app`, `something.replit.app`, a fresh preview URL on every deploy. That domain changes per project, and often per push.

Meta validates the domain that runs `FB.login`. So putting the Facebook button directly on your own page would mean registering every one of those domains in Pilot Status's Meta app, one by one, forever. That does not scale, and it is not offered.

The hosted button solves this by running the whole signup inside an `iframe` served from `connect.pilotstatus.com.br` — a domain Pilot Status already owns and already registered. **The Facebook SDK and the login popup live inside that iframe, not on your page.** Facebook checks the **iframe's** origin, not yours. Your page can live on any domain, including a preview URL that did not exist five minutes ago, and the popup opens normally.

**Nothing to register. Nothing to wait for. No SDK to load.** This is the correct path for builder-hosted apps, not a workaround. It is the same flow documented in [Embed the Connect page](/integrations/embed-connect) — this guide is the AI-builder, prompt-driven version of it.

<Note>
  If you own a stable domain and want the Facebook button on your own markup, use [Embedded Signup in your own app](/guides/embedded-signup) instead. It gives you full control of the button, at the cost of running the Facebook JavaScript SDK yourself.
</Note>

## Before you start

1. A Pilot Status account with a free number slot on your plan.
2. A **tenant-scoped** API key (`ps_...`) from the **API** tab of your profile (`/profile`). A number-scoped key will not work — generating a signup link creates a brand new number, so the key must not be bound to an existing one.
3. A backend. Every builder listed above can run one: a Replit server, a Next.js route handler on v0, a Supabase Edge Function on Lovable or Bolt. You need it because the browser cannot call the Pilot Status API directly — the only thing your backend does here is generate the connection link.

<Warning>
  **The `ps_` key is a server-side secret.** It authorizes everything in your account. It must never appear in frontend code, and never in a variable prefixed `VITE_`, `NEXT_PUBLIC_`, `REACT_APP_` or `PUBLIC_` — those are compiled into the JavaScript bundle your users download. Every prompt below repeats this, because AI builders get it wrong by default.
</Warning>

## How the pieces fit

```text theme={null}
Your page (any domain)
   │  1. POST /api/pilot/pairing-session   → { token, expiresAt }   (your backend generated the link)
   │  2. <iframe src="https://connect.pilotstatus.com.br/connect/<token>?embed=1&mode=button">
   │        ↑ the Facebook SDK and the login popup live HERE, inside Pilot Status's page
   │  3. postMessage "connect:paired" → { numberId, phone, displayName, provider, externalRef }
   ▼
Your backend  ──x-api-key: ps_──►  https://pilotstatus.com.br/v1/numbers/remote-pairing
```

Your page never loads the Facebook SDK, never serves an `appId` or `config_id`, never reads `/api/health`, and never calls `FB.login`. The `ps_` key only ever exists on the bottom row.

## The four prompts

Paste them in order. Each one is self-contained — the AI reading it cannot see this page, so everything it needs is written into the prompt itself.

### Prompt 1 — Backend route that generates the connection link

<CodeGroup>
  ```text Prompt 1 — backend theme={null}
  Add a backend endpoint to this project that generates a WhatsApp signup link
  from the Pilot Status API.

  Create a server-side route: POST /api/pilot/pairing-session

  It must accept a JSON body: { "customerId": string, "customerName": string }
  where customerId is the id of the logged-in customer in this app's own database.

  The route must call the Pilot Status API server-side:

    POST https://pilotstatus.com.br/v1/numbers/remote-pairing
    Headers:
      Content-Type: application/json
      x-api-key: <the value of the PILOT_TENANT_KEY environment variable>
    Body:
      {
        "provider": "META",
        "metaFlow": "embedded",
        "name": "<customerName>",
        "externalRef": "<customerId>"
      }

  A successful response is HTTP 201 with exactly this shape:

    {
      "provider": "META",
      "remotePairingUrl": "https://connect.pilotstatus.com.br/connect/<token>",
      "expiresAt": "2026-07-20T18:30:00.000Z"
    }

  The signup token is the LAST PATH SEGMENT of remotePairingUrl. Extract it with
  new URL(remotePairingUrl).pathname.split("/").pop(). The token is valid for 30
  minutes.

  Store a row in this app's database before responding: { customerId, token,
  expiresAt, status: "pending" }. You will need to match the result back to this
  customer later.

  Respond to the frontend with ONLY { token, expiresAt }. Never return the raw
  Pilot Status response and never return the API key.

  CRITICAL SECURITY REQUIREMENTS — do not deviate:

  - The Pilot Status API key starts with "ps_" and is a server-side secret. Read
    it ONLY from a server environment variable named PILOT_TENANT_KEY.
  - NEVER put the key in frontend code, and NEVER name the variable with a
    public prefix: not VITE_, not NEXT_PUBLIC_, not REACT_APP_, not PUBLIC_.
    Those prefixes ship the value inside the browser bundle where any user can
    read it.
  - NEVER call pilotstatus.com.br from browser code. Its CORS policy is a strict
    allowlist of Pilot Status's own domains, so a browser request from this app's
    origin will be blocked. Generating the link is the ONLY Pilot Status call in
    this whole integration, and it happens here, on the backend.
  - Do not log the API key or the token.

  Optionally, the request body sent to Pilot Status also accepts a "branding"
  object that styles the hosted button. Include it only if I ask for it:

    "branding": {
      "button": {
        "variant": "facebook",   // or "dark" | "light" | "outline"
        "label": "Connect WhatsApp",
        "primaryColor": "#1877F2",
        "textColor": "#FFFFFF",
        "radius": "md",          // "sm" | "md" | "lg" | "pill"
        "size": "md",            // "sm" | "md" | "lg"
        "fullWidth": true
      }
    }

  All branding fields are optional. Styling is accepted ONLY inside this signed
  token request — there is no query parameter for it, so do not try to pass
  colors or labels on the iframe URL.
  ```
</CodeGroup>

<Note>
  Generating the link creates a placeholder number and consumes a plan slot immediately. Generate it when the customer is actually about to click, not on page load. A link the customer abandons keeps its slot until you delete the placeholder number from the `/numbers` dashboard — there is no automatic cleanup.
</Note>

### Prompt 2 — Frontend iframe with the correct sandbox

<CodeGroup>
  ```text Prompt 2 — frontend iframe theme={null}
  Add a "Connect WhatsApp" section to the customer-facing page of this app that
  embeds the Pilot Status hosted signup button in an iframe.

  Behaviour:

  1. When the customer clicks "Connect WhatsApp", call this app's own backend:
     POST /api/pilot/pairing-session with { customerId, customerName }.
     It responds with { token, expiresAt }.

  2. Render an iframe with this exact src:

     https://connect.pilotstatus.com.br/connect/<token>?embed=1&mode=button&parentOrigin=<origin>

     where <token> is URL-encoded and <origin> is
     encodeURIComponent(window.location.origin).
     The mode=button parameter renders only the Facebook button — no header, no
     card, transparent background — so it drops into your own layout.

  3. The iframe MUST have this sandbox attribute, exactly:

     sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"

     allow-popups and allow-popups-to-escape-sandbox are MANDATORY. The Facebook
     login popup opens from inside the iframe. Without these two flags the popup
     is blocked silently: no error, no console message, the button just appears
     to do nothing. This is the single most common failure in this integration.

  4. Style the iframe: width 100%, border: 0, display: block, and a starting
     height of 48px. Do not set a background colour on it.

  5. Register a window "message" listener to receive events from the iframe.
     Validate EVERY message before using it — both checks, every time:

     - event.origin must be exactly "https://connect.pilotstatus.com.br"
     - event.data.source must be exactly "pilot-status-embed"

     Ignore any message that fails either check and return early. (Optionally, also
     require event.source === iframe.contentWindow for extra hardening.)

     Each message is an object shaped like this — only "type" is at the top level,
     everything else is inside "payload":

       { source: "pilot-status-embed", v: 1, type: "<event name>", payload: { ... } }

     Branch on message.type and read the data from message.payload. The events are
     "connect:paired" (payload has numberId, phone, displayName, provider,
     externalRef, redirectUrl), "connect:error" (payload.message),
     "connect:expired" (no payload), and "resize" (payload.height).

  6. Handle the "resize" event: payload is { height: number }. Set the iframe's
     style.height to that many pixels. The hosted page emits this continuously so
     the iframe fits its content with no scrollbar.

  7. Remove the listener when the component unmounts.

  Do NOT load any Facebook SDK in this app, do NOT call FB.login anywhere, and do
  NOT fetch /api/health or any other Pilot Status endpoint from the browser. The
  hosted iframe does ALL of the Facebook work — the SDK, the popup, the app id and
  config — on a domain that is already registered with Meta. Running any of that on
  this app's own domain would require registering this domain with Meta, which is
  not possible here.

  Do not call pilotstatus.com.br from this frontend code for any reason — CORS
  blocks it. The only network call the frontend makes is to this app's own
  backend.
  ```
</CodeGroup>

### Prompt 3 — Handle `connect:paired` and save the number

<CodeGroup>
  ```text Prompt 3 — handle the result theme={null}
  Extend the window "message" listener added for the Pilot Status signup iframe
  to handle the three result events. Keep the existing origin and source
  validation checks — apply them before anything below.

  Event: type === "connect:paired"
  The customer finished signup successfully. The payload is:

    {
      numberId:    string | null,   // the Pilot Status number id
      phone:       string | null,   // the connected phone number, digits
      displayName: string | null,   // WhatsApp display name
      provider:    string | null,   // "META" for this flow
      externalRef: string | null,   // the customerId sent when generating the link
      redirectUrl: string | null    // optional post-connect URL, if you set one
    }

  Every field is always present but any of them can be null, so never assume a
  value is there and never crash on a missing one. Do NOT auto-navigate to
  redirectUrl — the iframe leaves that decision to you.

  On this event:
    - POST the payload to this app's own backend at POST /api/pilot/paired.
    - Hide the iframe and show a success state with the phone number.

  Event: type === "connect:error"
  Payload is { message: string }. Show that message to the customer and offer a
  retry button that requests a fresh token from your backend and re-renders the
  iframe.

  Event: type === "connect:expired"
  The token passed its 30-minute lifetime. Do not reuse it. Show "This link
  expired" and a button that requests a fresh token from your backend and
  re-renders the iframe.

  Backend: add POST /api/pilot/paired.

  It must NOT trust externalRef from the request body to decide which customer
  this belongs to — a browser can send anything. Instead:

    - Identify the customer from the authenticated session on the request.
    - Look up the pending row this app stored when it generated the link
      (the one with status "pending" for that customer).
    - Update it to: { numberId, phone, displayName, provider, status: "connected",
      connectedAt: now }.
    - Treat externalRef from the payload as a cross-check only: if it does not
      match the stored customerId, log a warning and do not save.

  Make this handler idempotent — the same numberId may arrive twice. Match on
  numberId and update rather than inserting a duplicate row.

  Keep the Pilot Status API key out of all of this. Nothing in this step calls
  pilotstatus.com.br.
  ```
</CodeGroup>

### Prompt 4 — Register the webhook that delivers inbound messages

<CodeGroup>
  ```text Prompt 4 — webhooks theme={null}
  Add webhook registration to this app so it receives inbound WhatsApp messages
  for each connected number.

  Step 1 — register the webhook, server-side.

  After a number is connected (in the POST /api/pilot/paired handler, once
  numberId is known), call the Pilot Status API from the backend:

    POST https://pilotstatus.com.br/v1/webhooks
    Headers:
      Content-Type: application/json
      x-api-key: <the PILOT_TENANT_KEY environment variable, server-side only>
    Body:
      {
        "url": "https://<this app's public URL>/api/pilot/webhook",
        "whatsappNumberId": "<the numberId from the paired event>",
        "events": ["messages"]
      }

  The "events" array is REQUIRED in practice. Nothing is delivered unless events
  contains a matching event name or the wildcard "*". An empty array, or omitting
  the field, means this app receives absolutely nothing — and there is no error
  to tell you so. Always send an explicit events array.

  Step 2 — receive the deliveries.

  Add a public route: POST /api/pilot/webhook

  CRITICAL — the payload format for a META number is Meta's NATIVE envelope, not
  a Pilot Status event. It looks like this:

    {
      "object": "whatsapp_business_account",
      "entry": [
        {
          "id": "<waba id>",
          "time": 1753027200,
          "changes": [
            { "field": "messages", "value": { ...Meta's message payload... } }
          ]
        }
      ]
    }

  Parse it by reading body.entry[0].changes[0].field and branching on that value.
  Inbound messages arrive under field === "messages", with the actual messages in
  value.messages[] and contacts in value.contacts[].

  Do NOT write a handler that looks for an event named "message.received". A META
  number NEVER sends that. "message.received" belongs to a different, unofficial
  number type. Subscribing with events: ["*"] does not translate the payload
  either — it is always Meta's native envelope for a META number.

  Other things to get right:

  - A webhook created via POST /v1/webhooks has no secret, so these deliveries
    carry NO signature header — do not write signature verification and do not
    assume the request is authenticated. (If a secret is later set on the webhook,
    deliveries include an x-pilot-status-signature header holding an HMAC-SHA256 of
    the raw request body; only verify it once you have actually set a secret.) Use
    an unguessable path segment in the webhook URL, or an IP allowlist, if you need
    to harden it.
  - Return HTTP 200 immediately, then process asynchronously. Failed deliveries
    are retried 5 times with exponential backoff starting at 5 seconds, so slow
    or failing handlers cause duplicates.
  - Make the handler idempotent — deduplicate on the message id from Meta's
    payload.
  - The webhook URL must be publicly reachable over HTTPS. A localhost or
    preview-only URL will never receive anything.
  ```
</CodeGroup>

## Mistakes the AI usually makes

These five account for nearly every broken integration. If something does not work, check them in this order.

<AccordionGroup>
  <Accordion title="The ps_ key ends up in the browser">
    The builder reads it from `VITE_PILOT_KEY` or `NEXT_PUBLIC_PILOT_KEY` because that is the fastest way to make a fetch call compile. Those prefixes inline the value into the shipped bundle, so anyone who opens DevTools owns your account. The key belongs in a plain server-side variable such as `PILOT_TENANT_KEY`, read only from the backend route that generates the connection link. If you find it in the bundle, rotate the key in the dashboard before fixing the code.
  </Accordion>

  <Accordion title="The iframe sandbox is missing allow-popups">
    Without `allow-popups` and `allow-popups-to-escape-sandbox`, the Facebook popup is blocked by the browser with no error, no console warning and no visible failure. The button simply does nothing when clicked, which sends people hunting for a bug in their token logic. The full attribute is `sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"`.
  </Accordion>

  <Accordion title="Calling the Pilot Status API straight from the browser">
    The generated code does `fetch("https://pilotstatus.com.br/v1/...")` from a component. CORS on the API is a strict allowlist of Pilot Status's own domains, so a request from `*.lovable.app` or `*.replit.app` never receives an `Access-Control-Allow-Origin` header and is blocked. No API key changes this. In the hosted flow the browser never calls the Pilot Status API at all — it only embeds the iframe and calls your own backend, and your backend is the one that generates the connection link.
  </Accordion>

  <Accordion title="Registering a webhook without an events array">
    `POST /v1/webhooks` accepts a body with no `events`, returns success, and then delivers nothing. There is no warning. Send an explicit array — `["messages"]` for a META number, or `["*"]` for everything.
  </Accordion>

  <Accordion title="Loading the Facebook SDK or running FB.login on your own page">
    The AI knows the standard Facebook Embedded Signup recipe and will happily pull in `connect.facebook.net/en_US/sdk.js`, serve an `appId`/`config_id`, and call `FB.login` on your page. In this flow you do **none** of that: the SDK, the popup, and the Meta app all live inside the hosted iframe, on a domain that is already registered with Meta. Your `*.lovable.app` or `*.replit.app` URL is not — nor can it be, since it changes per project and per deploy. If a prompt starts loading the Facebook SDK, serving `appId`/`configId`, reading `/api/health`, or calling `FB.login`, you are on the wrong path — delete it and let the iframe do the work.
  </Accordion>
</AccordionGroup>

## A note on button styling

The hosted button's appearance comes from the `branding.button` object you send when you generate the connection link — see Prompt 1. That request is authenticated with your `ps_` key, so the styling is always attributable to your account.

There is deliberately **no query parameter** for button styling. Do not try to pass colours, labels or logos on the iframe URL; they will be ignored.

The "secured by pilotstatus.com.br" marker below the button renders by default. `branding.button.hideProvenance` can suppress it, but only in button mode and only when that capability is enabled for your account — otherwise the value is ignored and the marker still renders. Contact support if you need it.

## Next steps

The number is connected and webhooks are flowing. Now send something.

<CardGroup cols={2}>
  <Card title="Send messages" icon="paper-plane" href="/guides/send-messages">
    Your first send with `POST /v1/messages/send` — templates, free-form text and media.
  </Card>

  <Card title="Embed the Connect page" icon="window" href="/integrations/embed-connect">
    The full reference for the hosted flow — token shape, `mode=button`, the postMessage protocol and branding.
  </Card>

  <Card title="Embedded Signup in your own app" icon="facebook" href="/guides/embedded-signup">
    The full-control alternative, for when you own a stable domain and want the Facebook SDK on your own markup.
  </Card>
</CardGroup>
