This is the vendor-neutral implementer's guide to Meta's WhatsApp Embedded Signup: what the flow is, what you must configure before it will run, the exact client and server calls, and the parts that break in production. For how Dualhook itself drives the flow, see Embedded Signup.
Verified against Meta's WhatsApp Business Platform documentation on 14 August 2026.
Direct Answer
WhatsApp Embedded Signup is Meta's OAuth popup flow that lets a customer connect their own WhatsApp Business Account and phone number to your app in a few clicks, without ever handling an access token themselves.
You launch it from your own UI with the Facebook JavaScript SDK. The customer logs into Meta, picks or creates a Meta Business Portfolio, a WhatsApp Business Account, and a phone number, and approves your app's access. Meta returns a short-lived authorization code to your page and, separately, a postMessage carrying the waba_id and usually the phone_number_id. Your server exchanges that code for a customer-scoped Business Integration System User (BISU) access token, subscribes your app to the WABA, and configures webhooks.
Embedded Signup is the only supported way for a Tech Provider, Tech Partner, or Solution Partner to onboard a customer's WhatsApp assets at scale. There is no supported manual alternative for third-party onboarding: token copying by hand is a self-serve, single-business pattern, not an onboarding product.
What Embedded Signup Actually Is
Strip away the marketing and Embedded Signup is three things bolted together:
- A Facebook Login for Business configuration that declares which assets you want (WhatsApp Business Accounts and phone numbers), which permissions you want (
whatsapp_business_management,whatsapp_business_messaging), and what kind of token comes out. - A hosted multi-step wizard that Meta renders inside a popup. Business portfolio selection, WABA selection or creation, phone number entry, display name, verification code, and — in the Coexistence variation — the "connect your existing WhatsApp Business app account" branch.
- Two independent return channels. The OAuth authorization
codecomes back through theFB.logincallback. The IDs of what the customer actually selected come back through awindow.postMessagefromfacebook.com. These are separate and can arrive out of order, or one without the other. Nearly every hard onboarding bug in this flow traces back to treating them as one channel.
The resulting token is a Business Integration System User token: it belongs to the customer's business, is scoped to the assets they granted, and is issued to your app. It is not your app token, and it is not a user token. It should never be shown to the customer or embedded in a browser.
Prerequisites
Before the popup will open at all:
| Requirement | Where | Notes |
|---|---|---|
| Meta app with the WhatsApp product added | Meta App Dashboard | Business-type app |
| Facebook Login for Business v4 configuration | App Dashboard, Embedded Signup Builder | The config_id you pass to FB.login |
whatsapp_business_management | App permissions | Manage WABAs, templates, phone numbers |
whatsapp_business_messaging | App permissions | Send and receive messages |
Advanced Access for both scopes plus public_profile | App Review | Standard Access only works for people with a role on your app |
| Verified Meta Business Portfolio | Meta Business Settings | Your own portfolio, not the customer's |
| HTTPS domain in Allowed Domains | Facebook Login settings | Exact host, no wildcards under Strict Mode |
| Exact HTTPS callback in Valid OAuth Redirect URIs | Facebook Login settings | Must match byte for byte |
| A live webhook endpoint | Your infrastructure | Must answer Meta's verification challenge before you can route anything |
Two of these fail quietly and cost people days:
- Advanced Access. With Standard Access the popup runs fine for you and fails for every real customer. Test with an account that has no role on your app.
- Strict Mode redirect URIs. Meta compares the redirect URI literally, including trailing slashes and query strings.
The Flow, End to End
- Your customer clicks Connect WhatsApp in your product.
- You collect anything you need up front — for example the customer's own webhook URL and verify token, if you intend to route webhooks directly to them.
- You call
FB.loginwith yourconfig_idandresponse_type: "code". Meta opens the popup. - The customer authenticates, picks a business portfolio, picks or creates a WABA, and adds or selects a phone number.
- Meta posts a
WA_EMBEDDED_SIGNUPmessage to your page containingwaba_idand, in most flows,phone_number_id. - The
FB.logincallback fires withauthResponse.code. - Your server exchanges the code for a BISU token, subscribes your app to the WABA, configures webhooks, and registers the number.
Steps 5 and 6 race. Handle that explicitly — see Step 4.
Step 1: Create the Login for Business v4 Configuration
In the Meta App Dashboard, open the Embedded Signup Builder and create a Facebook Login for Business v4 configuration with the WhatsApp Embedded Signup variation.
Configure it with:
- the Cloud API product selected
- WhatsApp Business Accounts and WhatsApp phone numbers as the selected assets
- a Business Integration System User token with never-expiring access
- exactly two permissions:
whatsapp_business_managementandwhatsapp_business_messaging - nothing else selected — no
business_management, no ad accounts, no Pages, no Instagram, no datasets, no catalogs, no Marketing Messages Lite
Requesting scopes you do not need is the most common reason an App Review submission stalls, and every extra asset type you select adds a consent screen your customer has to read.
Under Facebook Login → Settings, enable Client OAuth Login, Web OAuth Login, Enforce HTTPS, Embedded Browser OAuth Login, Strict Mode for redirect URIs, and Login with the JavaScript SDK.
The configuration produces a configuration ID. That is the config_id your client code passes. Treat it as environment configuration, not a constant: you will eventually need to swap it, and you want that to be a deploy, not a code change.
Step 2: Load the Facebook JavaScript SDK
<script async defer crossorigin="anonymous"
src="https://connect.facebook.net/en_US/sdk.js"></script>
window.fbAsyncInit = function () {
FB.init({
appId: process.env.NEXT_PUBLIC_META_APP_ID,
autoLogAppEvents: true,
xfbml: true,
version: "v25.0",
});
};
The SDK must be loaded before the customer can click your button. If window.FB is undefined when they click, fail loudly with a "refresh and try again" message rather than silently doing nothing — a dead button is indistinguishable from a broken product.
Step 3: Launch the Popup with FB.login
FB.login(
(response) => {
if (response.authResponse?.code) {
// Send the code to your server. Never exchange it in the browser:
// the exchange requires your app secret.
void completeSignup(response.authResponse.code);
return;
}
// status === "unknown" covers cancellation, popup blocking, and
// documented Embedded Signup errors. Classify with the postMessage.
handleAbandonedSignup();
},
{
config_id: process.env.NEXT_PUBLIC_META_CONFIG_ID,
response_type: "code",
override_default_response_type: true,
extras: {
setup: {},
// Coexistence only. Omit featureType for a plain Cloud API flow.
featureType: "whatsapp_business_app_onboarding",
},
}
);
What each field does:
| Field | Purpose |
|---|---|
config_id | The Login for Business v4 configuration from Step 1 |
response_type: "code" | Returns an authorization code instead of a browser access token |
override_default_response_type: true | Required, or the SDK falls back to its default token response |
extras.setup | Prefill object. An empty object means "ask the customer for everything" |
extras.featureType | Launch selector. whatsapp_business_app_onboarding offers the Coexistence branch |
Under v4, the Builder configuration — not the JavaScript call — controls products, assets, and permissions. The generic feature: "whatsapp_embedded_signup" override belongs to older versions and does nothing here.
sessionInfoVersion is a more nuanced case. Meta's Coexistence page still shows "sessionInfoVersion": "3" in its sample extras object, but the v4 page makes no mention of it, and a v4 configuration works without it — Dualhook's own production integration omits it and still receives the Coexistence completion payload, which reports version: 3 regardless. Treat it as vestigial rather than required, and if you are debugging an unexplained flow difference, adding it back is a cheap thing to test.
The one thing v4 genuinely does not move into the Builder is the launch selector. Meta's Version 4 page is explicit that "onboarding WhatsApp Business app users continues to be supported through the feature_type parameter," so featureType: "whatsapp_business_app_onboarding" still has to be passed in extras for the Coexistence screen to appear, even on a v4 configuration that has Coexistence enabled. A bare extras: { setup: {} } runs a clean Cloud API onboarding and quietly hides the Coexistence option — which is the correct call if you do not support Coexistence, and a silent bug if you do.
FB.login must be called synchronously inside the click handler. If you await anything first, the browser treats the popup as unsolicited and blocks it.
Step 4: Capture the Session Info Message
Meta sends the selected asset IDs through postMessage, not through the login callback.
const listener = (event) => {
if (
event.origin !== "https://www.facebook.com" &&
event.origin !== "https://web.facebook.com"
) {
return;
}
let data;
try {
data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
} catch {
return;
}
if (data.type !== "WA_EMBEDDED_SIGNUP") return;
const event_name = String(data.event ?? "").toUpperCase();
if (data.data?.waba_id) {
sessionInfo = {
waba_id: data.data.waba_id,
phone_number_id: data.data.phone_number_id ?? null,
};
}
if (event_name === "FINISH" ||
event_name === "FINISH_ONLY_WABA" ||
event_name === "FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING") {
// Meta linked the account. The code may still be in flight.
} else if (event_name === "CANCEL") {
// data.data.current_step tells you where they dropped out.
} else if (event_name === "ERROR") {
// data.data.error_id / error_message / session_id
}
};
window.addEventListener("message", listener);
Five rules that separate a reliable integration from a flaky one:
- Always check
event.origin. Any page can post a message to yours. Accept onlyhttps://www.facebook.comandhttps://web.facebook.com. - Expect
phone_number_idto be missing. The Coexistence completion event routinely omits it. Fall back to server-side discovery rather than failing the connection. - Do not treat
FINISHas success.FINISHmeans Meta linked the account. It does not mean the authorization code reached your server. Those are different outcomes with different recoveries. - Do not treat
PARTNER_ADDEDorPARTNER_APP_INSTALLEDas completion. They fire mid-flow. They are corroborating evidence, not proof. - Give the message a grace period. When the callback fires first, poll for the session info for a few seconds before giving up on it.
Do not log the raw event. It can carry localized error text and selected-business payloads you have no reason to retain. Store the numeric error ID, Meta's session ID, and the failed step, and nothing else.
Step 5: Exchange the Code for a Business Token
Server-side only. This call needs your app secret.
curl -X GET "https://graph.facebook.com/v25.0/oauth/access_token" \
-d "client_id=<APP_ID>" \
-d "client_secret=<APP_SECRET>" \
-d "code=<AUTH_CODE>"
You get back a Business Integration System User access token scoped to the assets the customer granted. Under a correctly built v4 configuration it does not expire.
Immediately after the exchange, call debug_token to find out what you actually received:
curl -X GET "https://graph.facebook.com/v25.0/debug_token?input_token=<BISU_TOKEN>" \
-H "Authorization: Bearer <APP_ACCESS_TOKEN>"
The response's granular_scopes tells you which WABA target_ids carry whatsapp_business_management and whatsapp_business_messaging. This is your ground truth when the postMessage never arrived, and it is the first thing to check when sends later fail with error 200 or error 10.
Handling rules:
- Store the token encrypted at rest. Never return it to the browser, never show it in your UI, never put it in a log line.
- Record
issued_atandexpires_atso you can warn before a non-permanent token dies. A BISU token is not refreshable through the ordinary System User refresh flow; recovery means the customer re-runs Embedded Signup. - Once you hold a token, every subsequent Graph call should be sent with
appsecret_proof. Without it, a leaked token is usable by anyone who has it. - The code exchange itself takes no
appsecret_proof— there is no token yet.
Authorization codes are single-use and short-lived. If your server retries the exchange after a network timeout, the second attempt fails; treat it as terminal and have the customer restart.
Step 6: Subscribe Your App and Route Webhooks
A token alone delivers nothing. Two more calls:
# 1. Subscribe your app to the customer's WABA
curl -X POST "https://graph.facebook.com/v25.0/<WABA_ID>/subscribed_apps" \
-H "Authorization: Bearer <BISU_TOKEN>"
# 2. Optional: override the callback so Meta delivers straight to the customer
curl -X POST "https://graph.facebook.com/v25.0/<WABA_ID>/subscribed_apps" \
-H "Authorization: Bearer <BISU_TOKEN>" \
-d "override_callback_uri=https://customer.example.com/webhooks/whatsapp" \
-d "verify_token=<CUSTOMER_VERIFY_TOKEN>"
Order matters. Overriding before subscribing returns Graph error 100 with "Before override the current callback uri" — see Webhook Error 100.
Whichever endpoint receives the traffic must already answer Meta's verification challenge. Meta sends a GET with hub.mode=subscribe, hub.verify_token, and hub.challenge. Compare the verify token, then return the raw hub.challenge value as plain text with HTTP 200 — not JSON, not quoted, not wrapped.
app.get("/webhooks/whatsapp", (req, res) => {
const mode = req.query["hub.mode"];
const token = req.query["hub.verify_token"];
const challenge = req.query["hub.challenge"];
if (mode === "subscribe" && token === process.env.VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
Set this endpoint up before you run Embedded Signup, not after. Meta verifies at configuration time, and a 404 at that moment turns a clean onboarding into a support ticket.
Webhook Override is what makes direct routing possible: message-path fields go from Meta to the customer's own server, while management events stay on your app-level callback. See the webhook subscription field reference for which fields are overridable.
Step 7: Register the Phone Number
A number selected in the popup is not yet usable by Cloud API. Register it:
curl -X POST "https://graph.facebook.com/v25.0/<PHONE_NUMBER_ID>/register" \
-H "Authorization: Bearer <BISU_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"messaging_product":"whatsapp","pin":"<SIX_DIGIT_PIN>"}'
The pin is the number's two-step verification PIN. If two-step verification was never set, you are setting it here. If it was set and you do not know it, registration fails and the customer must reset it in WhatsApp Manager.
Two failures are common enough to name:
- Subcode 2388001 — Meta does not publicly document this subcode. It is commonly seen when a number is still attached to WhatsApp, the WhatsApp Business app, or a previous provider, but confirm the actual ownership state before changing anything. Use Coexistence if the business must keep the app, and do not delete or deregister an account as a generic fix.
- Error 133016 — too many registration attempts. Retrying faster makes the cooldown longer.
Coexistence: Onboarding WhatsApp Business App Users
Most businesses that want the API already use the WhatsApp Business app, and most of them do not want to give it up. Coexistence is Meta's supported answer: the app and Cloud API run on the same number.
Onboarding differences you have to plan for:
- Pass
featureType: "whatsapp_business_app_onboarding"inextras. Without it, the customer never sees the "connect your existing WhatsApp Business app account" screen. - The completion event is
FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING, and its payload can omitphone_number_id. Discover the number server-side from the WABA. - The customer completes a QR-code or in-app confirmation step on the phone itself. That is outside your UI — build for the wait.
- After the override is accepted, request the one-time contact sync (
smb_app_state_sync) and then chat-history sync (history). Both are one-shot and time-sensitive. If one request fails, still attempt the other. - Coexistence adds an ongoing operational requirement: the app must be opened at least once every 13 days. See Heartbeat.
- Messages sent from the app arrive as
smb_message_echoes, not asmessages.
If the number was previously onboarded by another provider, Embedded Signup can fail before it grants you anything with #2655093 — see Error 2655093.
Embedded Signup Versions: v2, v3, and v4
| Version | How it is configured | Status |
|---|---|---|
| v2 | Assets and permissions declared in the FB.login call | Legacy. Meta has set 15 October 2026 as its end of life |
| v3 | Required extras.version: "v3" to opt in | Transitional |
| v4 | Assets, permissions, and products declared in the Embedded Signup Builder configuration | Current |
A detail that catches teams out during the audit: if you never explicitly passed extras.version: "v3", you were on v2 regardless of when you built your integration. Opting into v3 was never automatic, so "we built this recently" is not evidence of anything. Check the actual extras object you send.
Migrating to v4 is not only a code change. You need a new configuration built in the Builder with the products and assets selected, plus the code change that stops sending v2-style launch parameters. Ship both together, and validate the new configuration against both a plain Cloud API onboarding and a Coexistence onboarding — including the chat-history opt-in branch — before pointing production at it. A configuration that works for one and silently breaks the other is the normal failure, not the exotic one.
Embedded Signup vs Manual Onboarding
| Embedded Signup | Manual setup | |
|---|---|---|
| Who does the work | The customer, in a popup | Your engineers, per customer |
| Token handling | Server-side code exchange | Customer copies a token into your UI |
| Asset selection | Meta's wizard | Manual ID collection |
| Number registration | Part of the flow | A separate Graph call you script |
| Webhook setup | Configurable during onboarding | Manual per WABA |
| Coexistence support | Yes, via the launch selector | Not available |
| Scales to many customers | Yes | No |
| Supported by Meta for third-party onboarding | Yes | No |
Manual token entry remains reasonable for exactly one case: a single business wiring up its own number for its own backend. The moment there is a second business, you are building an onboarding product, and Embedded Signup is the supported shape.
What Goes Wrong, and How to Read It
| Symptom | Likely cause | Where to look |
|---|---|---|
| Popup never opens | FB.login called after an await, or SDK not loaded | Call it synchronously in the click handler |
| Works for you, fails for customers | Standard Access instead of Advanced Access | App Review |
status: "unknown", no code | Popup blocked, cancelled, or a documented Meta error | Classify using the postMessage |
FINISH received, no connection created | The code did not return to your server | Connection Not Appearing |
| Code exchange returns an OAuth error | Redirect URI mismatch, reused code, or wrong app secret | Strict Mode redirect URIs |
#2655093 in the popup | The number is still shared with a previous partner | Error 2655093 |
| Webhook override rejected with code 100 | Override attempted before subscribing | Webhook Error 100 |
| Registration fails with subcode 2388001 | Number still attached to WhatsApp, the Business app, or a previous provider | Confirm the ownership state; use Coexistence if the app must stay. Do not delete an account as a generic fix |
| Sends fail with 200 or 10 after onboarding | Token lacks the scope for that WABA | debug_token granular scopes |
The full index is at Embedded Signup Errors; numeric Cloud API codes are at WhatsApp Cloud API Error Codes.
Implementation Checklist
- Meta app with the WhatsApp product, business type
- Login for Business v4 configuration built in the Embedded Signup Builder
- Advanced Access approved for
whatsapp_business_management,whatsapp_business_messaging, andpublic_profile - Allowed Domains and Valid OAuth Redirect URIs set exactly, with Strict Mode on
- Webhook endpoint live and answering
hub.challengebefore you run the flow FB.logincalled synchronously withconfig_id,response_type: "code", andoverride_default_response_type: truefeatureType: "whatsapp_business_app_onboarding"inextrasif you support Coexistence- Origin-checked
postMessagelistener that tolerates a missingphone_number_id - Server-side code exchange, then
debug_tokento record granular scopes - Token encrypted at rest, never returned to the browser,
appsecret_proofon every later call subscribed_appsbefore anyoverride_callback_uri- Phone number registered with a known two-step PIN
- A recovery path for
FINISHwithout a resulting connection - Tested end to end with an account that has no role on your app
Where Dualhook Fits
Dualhook is a badged Meta Tech Partner and runs this flow as a product. The Meta app, the v4 configuration, App Review, the code exchange, token encryption, debug_token scope capture, subscribed_apps, Webhook Override, and the recovery path for a FINISH that never returned an authorization are all handled for you.
The part worth knowing about is where the traffic goes afterwards. Dualhook asks for your webhook URL and verify token before the popup opens and configures a WABA-level override at completion, so message-path webhooks go from Meta straight to your endpoint. Dualhook is not in the message path and does not store message content. See Webhook Override and WhatsApp API Privacy.
If you are building the flow yourself, the checklist above is the whole job. If you would rather not own App Review, configuration migrations, and popup-error triage, that is what Dualhook is for.
FAQ
What is WhatsApp Embedded Signup?
Meta's OAuth-based onboarding flow. It lets a customer connect their own WhatsApp Business Account and phone number to a third-party app through a popup. The customer authorizes access, Meta returns a short-lived authorization code, and the integrator exchanges it server-side for a Business Integration System User access token. The customer never handles the token.
Who can use Embedded Signup?
Any Meta partner that has added the WhatsApp product to a business-type app and holds Advanced Access for whatsapp_business_management and whatsapp_business_messaging — Tech Providers, Tech Partners, and Solution Partners. It is the supported path for onboarding customer-owned WhatsApp assets at scale.
What do I need before I can run the flow?
A Meta app with the WhatsApp product, a Facebook Login for Business v4 configuration created in the Embedded Signup Builder, Advanced Access for the two WhatsApp scopes plus public_profile, exact HTTPS entries in Allowed Domains and Valid OAuth Redirect URIs, and a live webhook endpoint that answers Meta's hub.challenge verification.
How does the token exchange work?
Meta returns a short-lived authorization code to your page through the FB.login callback. You send it to your server, which calls GET /v25.0/oauth/access_token with your app ID, app secret, and the code. The response is a Business Integration System User token scoped to the assets the customer granted. Store it encrypted; never return it to the browser.
Why is phone_number_id missing from the session info?
Because Meta does not always include it, particularly in the Coexistence completion event FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING. Fall back to server-side discovery: call debug_token, read the WABA target_ids from granular_scopes, and enumerate the phone numbers under each WABA.
Does a FINISH event mean the signup succeeded?
No. FINISH means Meta linked the account. The authorization code travels on a separate channel and can still fail to reach your server. Treat "Meta linked it but we never got the code" as its own state with its own recovery, rather than as either success or failure.
What is the difference between Embedded Signup v2, v3, and v4?
v2 declared assets and permissions in the FB.login call. v3 required an explicit extras.version: "v3" opt-in. v4 moves products, assets, and permissions into the Embedded Signup Builder configuration, so the JavaScript call carries only config_id, the response type, and the launch selector. Meta has set 15 October 2026 as the end of life for v2.
How do I onboard a business that already uses the WhatsApp Business app?
Pass featureType: "whatsapp_business_app_onboarding" in extras. That launch selector is what surfaces the Coexistence branch, and it is still required under v4 even when the Builder configuration has Coexistence enabled. Expect the completion event FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING, and expect it to sometimes omit the phone number ID.
Can I exchange the authorization code in the browser?
No. The exchange requires your app secret, which must never reach a browser. Post the code to your own server and exchange it there. Codes are single-use and short-lived, so a retried exchange after a timeout will fail.
Sources
- Meta, WhatsApp Business Platform Embedded Signup
- Meta, Facebook Login for Business
- Meta, Embedded Signup version 4
- Meta, Cloud API phone number registration
- Meta, WhatsApp Business Platform webhooks
- Meta, Onboarding WhatsApp Business app users
All checked 14 August 2026. Meta is the authority for every field name, scope, and date above; re-verify before you commit a migration date to a plan.