Skip to main content
Switchbord workspaces are no longer limited to a single WhatsApp Business Account (WABA). A workspace can onboard additional WABAs through Meta’s Embedded Signup flow — Meta’s own hosted sign-in popup — without ever touching a manually-copied access token. Each WABA gets its own isolated credential slot so a second (or third) number never overwrites the first.
Embedded Signup is additive. The original bring-your-own-token wizard at /welcome is unchanged and always available — Embedded Signup is a second, optional path for workspaces that want Meta-hosted onboarding instead of pasting a System User token.

Three onboarding paths

When Embedded Signup is enabled, /welcome presents a chooser (OnboardingPathChooser) with three cards instead of jumping straight into the manual wizard: Picking “Onboard with us” or “Migrate existing WhatsApp account” both render the same EmbeddedSignupCard — the only difference is the path value (onboard vs migrate) sent to the exchange endpoint, and Meta’s own signup UI adapts its copy accordingly. A Enable Marketing Messages checkbox appears when a marketing-scoped Meta config ID is configured, and switches which Facebook login config_id is used.
Every screen in the chooser has a “Choose a different method” back button, so an operator who starts Embedded Signup and hits a dead end (ad blocker, closed popup, Meta error) can always fall back to the manual token wizard without reloading the page.

Feature flag and prerequisites

Embedded Signup is off by default and gated on both a public client flag and server-side app credentials: The server-side check is deliberate defense in depth: flipping the client flag alone can never expose a working Embedded Signup flow without the app credentials also being configured.

How the exchange works

  1. The browser loads the Facebook JS SDK once (connect.facebook.net/en_US/sdk.js) and calls FB.login() with the resolved config_id, requesting an authorization code.
  2. Meta’s popup also posts a WA_EMBEDDED_SIGNUP postMessage event (validated against a facebook.com / *.facebook.com origin) carrying the waba_id and phone_number_id the operator selected inside Meta’s own UI.
  3. Once both the code and the WABA/phone pair have arrived, the card POSTs once to /api/onboarding/meta/exchange with { code, wabaId, phoneNumberId, path, includeMarketingMessages }.
  4. The route (apps/app/app/api/onboarding/meta/exchange/route.ts) runs a strict server-side sequence:
    • exchanges the code for a business access token (exchangeEmbeddedSignupCode);
    • inspects the token with debugToken and requires both whatsapp_business_management and whatsapp_business_messaging scopes;
    • if Meta’s granular scopes enumerate granted WABA ids, the supplied wabaId must be in that list — a client-supplied WABA id is never trusted blindly;
    • confirms the phone number actually belongs to that WABA via listWabaPhoneNumbers before doing anything else;
    • generates and stores a one-time 6-digit registration PIN (meta-phone-pin secret) and calls registerPhoneNumberForCloudApi — a Meta registration failure (already registered, 2FA mismatch on migrate) is tolerated and reported back as phoneRegistered: false rather than failing the whole request;
    • generates a per-workspace verify token and subscribes the Switchbord app to the WABA with a workspace-scoped override_callback_uri (.../webhooks/meta?w=<workspaceId>);
    • resolves which credential slot this WABA’s token belongs in (see below), stores the token, and imports the phone number into a channel.
  5. The response never contains the access token, the PIN, the verify token, META_APP_SECRET, or any appsecret_proof — only channelId, wabaId, phoneNumberId, displayName, phoneRegistered, and marketingMessagesEnabled.
The exchange route enforces a strict secret-leak invariant: detail fields on error responses may only ever carry Meta’s own sanitized error message. Tokens, PINs, verify tokens, and the app secret are never logged, captured in exception telemetry, or echoed back to the browser.

Multi-WABA token isolation

Before this work, every workspace’s Meta token lived under one Vault secret kind (meta-access-token) — a second WABA connected via Embedded Signup would silently overwrite the first WABA’s token and break its sends. Switchbord now resolves each WABA’s token independently:
  • The default provider account (the first WABA in a workspace, or one already flagged default) keeps using the meta-access-token secret kind — single-WABA workspaces are completely unaffected.
  • Each additional WABA claims the first free slot from meta-access-token-2 through meta-access-token-8 (allocateWabaTokenSlotKind), recorded on whatsapp_provider_accounts.credential_secret_kind. Eight slots comfortably exceeds any realistic per-workspace WABA count.
  • resolveChannelAccessToken(admin, { workspaceId, channelId | channel }) is the single resolver used by every send hot path (message dispatch — which also covers campaign sends — media sends, interactive sends, and template sync). It resolves channel → provider account → credentialSecretKind, reads that Vault slot, and falls back to the workspace-default meta-access-token and then the env WHATSAPP_META_ACCESS_TOKEN if the slot is empty. It never throws on a missing token — callers fail the dispatch loudly exactly as before.
This is transparent to operators — there is no per-WABA token field to manage in Settings. The slot kind is chosen automatically at onboarding time and read automatically at send time. Settings → Provider continues to show only the operator-facing credential kinds; the WABA slot kinds are internal.

Verifying a multi-WABA workspace

  • Check whatsapp_provider_accounts for the workspace: the default row has is_default = true and credential_secret_kind = 'meta-access-token'; every additional WABA row has its own credential_secret_kind slot and is_default = false.
  • A send for a non-default WABA that reports the wrong sender number almost always means its provider account’s credential_secret_kind is missing or points at the wrong slot — check setProviderAccountCredentialKind was called during that WABA’s onboarding.
  • If Embedded Signup completed but phoneRegistered came back false, the phone number connected to the channel but is not yet registered for Cloud API sends — finish registration from Settings before expecting sends to go out.

See also