# Embedded Signup (Tech Provider) + Coexistence

**Portal:** Manage · **Routes:** `manage.messages.channels.embedded-signup` (+ the public `webhooks.whatsapp.cloud`) · **Nav:** Messages → Settings → Channels → *Add channel* · **Parent:** [WhatsApp readMe](/docs/modules_handbook/manage/messages/whatsapp/readMe.md)

## What it does
Two ways to connect an official Cloud API number now exist side by side on *Add channel → Cloud API*:

| | **Connect with Facebook** (Embedded Signup) | **Enter IDs manually** (Partner sharing) |
|---|---|---|
| Who does what | The customer signs in to Meta in a popup, picks/creates their WABA + number, done. | The customer shares their WABA with our Business Portfolio, then types the WABA id + phone number id. |
| Whose token sends | **Theirs** — a per-WABA *Business Integration System User* token, obtained once, never expires, stored encrypted on the channel. | **Ours** — the central platform System User token (`WHATSAPP_ACCESS_TOKEN`). |
| Meta model | **Tech Provider** (we are the app the customer authorises). | Partner sharing. |
| Coexistence | Available (the *Keep using the WhatsApp Business App* checkbox). | Not available. |
| When shown | Only when `WHATSAPP_ES_CONFIG_ID` is set. | Always (the only path when not configured). |

**Coexistence** = the same number stays signed in to the **WhatsApp Business App** on the owner's phone *and* runs on the Cloud API. Meta mirrors both sides into this inbox: what the owner types on the phone shows up here as an outbound bubble with a **Phone** badge, customer replies reach both the phone and the inbox, and on connect the phone's last **6 months of 1:1 chats + its address book** are imported through the existing history-sync pipeline (banner, settle job, unmatched-leads card). What it never does: answer the owner's own messages with AI / flows, move the 24-hour window, create leads from the address book, or sync groups.

## How it works

### Onboarding (browser → code → token)
1. `ChannelsController::index` passes `embeddedSignup` = `EmbeddedSignupClient::browserConfig()` (`app_id`, `config_id`, `graph_version`) or `null`.
2. `ChannelFormModal.vue` preloads the Facebook JS SDK when the modal opens (`composables/useFacebookSdk.js` → `loadFacebookSdk`). **`FB.login` must be called synchronously inside the click handler** — anything awaited first and the browser blocks the popup as unsolicited; that is why the SDK is loaded ahead of time and `launchEmbeddedSignup` never awaits before calling it.
3. The popup reports its outcome on **two independent channels** that can land in either order: `FB.login`'s callback carries the ~30-second **code** (`response_type: 'code'`), and a `window` `message` from `https://www.facebook.com` / `https://web.facebook.com` (`type: WA_EMBEDDED_SIGNUP`) carries `{ event, data: { waba_id, phone_number_id?, business_id } }`. The modal submits once it has both. `CANCEL` (with `current_step`) and `ERROR` (with `session_id`, quote it to Meta support) are shown inline.
4. The Coexistence checkbox adds `extras.featureType = 'whatsapp_business_app_onboarding'`; Meta then swaps the "create a WABA" step for "connect your existing WhatsApp Business app account" and asks the phone to confirm. Its FINISH event is `FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING` and **commonly omits `phone_number_id`**.
5. `POST channels/embedded-signup` (`EmbeddedSignupRequest` → `ChannelsController::storeEmbeddedSignup`): `EmbeddedSignupClient::exchangeCode` (`GET /oauth/access_token` with our app id + secret + the code — **no redirect_uri**, the popup minted the code) → `debugToken` (validity + scopes, logged) → when the number is missing, `listPhoneNumbers` on the WABA with the customer's token (exactly one → used, `is_on_biz_app` also flips Coexistence on; several → refused with a pointer to the manual form) → duplicate `(provider, provider_ref)` refused in words → `WhatsappRepository::createChannel` with `provider_config = {access_token, phone_number_id, business_account_id, business_id, register_pin, onboarding: 'embedded_signup', token_obtained_at, coexistence}` → the same `finalizeCloudOnboarding` (register with our generated PIN + `subscribed_apps`) and `ChannelInfoSync::sync` as the manual path → for Coexistence, the two sync requests below.
6. **Token precedence is already the driver's rule:** `CloudApiDriver::client()` uses `provider_config['access_token']` when present, else the central token. Nothing else changed — every call on an Embedded-Signup channel (send, media, templates, register, subscribe, quality) runs on the customer's own token. `EmbeddedSignupClient` is deliberately bound to the **WhatsApp app** (`whatsapp.cloud.app_id` / `app_secret`, `whatsapp.graph_version`), never `services.facebook.*` (the FLG app, another Graph version): a code is only exchangeable by the app that issued it.

### Coexistence: the three webhook fields
All arrive on the existing `POST /webhooks/whatsapp/cloud` (`WhatsAppWebhookController::handleCloud`, same signature check), routed by `changes[].field`:

| Field | What | Lane | Parser → job path |
|---|---|---|---|
| `smb_message_echoes` | A message the **owner sent from the phone** (`message_echoes[]{from: business, to: customer, id, timestamp, type, …}`). | default (live) | `CloudApiDriver::parseInboundWebhook` → `normalizeEcho` → `NormalizedMessage` with `direction OUT`, `fromPhone = to` (the peer), `source = smb_echo` → `recordInbound` (status SENT, `last_inbound_at` untouched, preview `Phone: `). `type: edit` / `revoke` → `parseMessageMutations` → the job's existing `editMessage` / `revokeMessage`. |
| `history` | The phone's past 1:1 chats, in **phases 0 / 1 / 2** (day 0-1, 1-90, 90-180) of chunks, each with `metadata.{phase, chunk_order, progress}` and `threads[]{id: customer phone, messages[]{…, history_context{status, from_me}}}`. | **`redis-broadcast` / `broadcast`** | `parseHistorySet` → the Bridge batch shape (`messages`, `sync_type = phase`, `progress` folded across phases as `(phase*100 + progress) / 3`, `is_latest`) → `processHistorySet` unchanged: historical rows, per-`from_me` direction, the phone's status kept (`NormalizedMessage::$status` → `recordInbound` advances delivered/read), banner + `LinkChannelContacts`. A `media_placeholder` becomes a `TYPE_SYSTEM` "Media (syncing…)" row flagged `meta.media_placeholder`; when the real media arrives under the **same wamid** the job calls `WhatsappRepository::upgradeMediaPlaceholder` first (the unique index would otherwise drop it). `parseHistoryStatus` → `complete` on the final phase at 100 %, or `declined` (Meta error `2593109`, the owner said no) → `processHistoryStatus` closes the sync out and requests the contacts sync. |
| `smb_app_state_sync` | The phone's **address book** (`state_sync[]{type: contact, action: add\|remove, contact{full_name, first_name, phone_number}}`). | **`redis-broadcast` / `broadcast`** | `parseContactUpdates` (only `add`; `remove` never clears a name) → `processContactUpdates` → `enrichContactNames` — names contacts that already exist, **never creates one**. |
| `account_update` `event: PARTNER_REMOVED` | The owner disconnected the number from the Business Platform in the app (Settings → Business Platform). | default | `parseAccountUpdates` (`AccountUpdate::$disconnected`) → `processAccountUpdates`: `STATUS_DISCONNECTED`, `coexistence = false` + `smb_disconnected_at`, active broadcasts paused (`PAUSE_INVALID_CHANNEL`). Names no number we know → every channel on the WABA. |

**Why the lanes.** Same two reasons as the Bridge's `history.set` (see `handleBridge`): the address book must be processed *after* the history that creates the contacts it names (FIFO), and a 6-month backfill arrives in bursts that must never drown live inbound; `processHistorySet`'s `markHistorySyncStarted` also assumes a single process. `CloudApiDriver::pacedFields()` decides per payload — a payload is paced when *any* change is a history / state-sync field.

**Why an echo never automates anything.** Every `consider*` branch in `ProcessInboundWhatsAppWebhook` is `isInbound()`-guarded (AI reply, flow, CTA capture, opt-out, contact link + lead creation, notify); `considerAiCallButton` had no guard and gained one (an owner forwarding one of our quick-reply buttons from the phone must not place a call). `downloadMedia` + `NewWhatsAppMessage` still run for an echo — the owner's photo should appear live.

### The one-shot syncs (`RequestCoexistenceSync`)
Meta allows **each** sync to be requested **once per onboarding, within 24 h**: `POST /{phone_number_id}/smb_app_data {messaging_product: 'whatsapp', sync_type: 'history' | 'smb_app_state_sync'}` (`CloudApiDriver::requestSmbAppData`). The job is `ShouldBeUnique` per `(channel, sync type)`, idempotent on `provider_config['smb_history_requested_at' | 'smb_contacts_requested_at']` (written through `WhatsappRepository::mergeChannelProviderConfig`, the one writer for every post-creation provider flag), and treats an "already requested" Graph error as done. Sequencing: onboarding dispatches **history immediately** and **contacts with a 20-hour delay** (`CONTACTS_FALLBACK_HOURS`); `processHistoryStatus` dispatches contacts **early** on `complete` / `declined`. Contacts follow history because the address book only *enriches* contacts history created. The history request also `markHistorySyncStarted`s (banner from the moment we asked), so a decline has something to close.

**Settle timing.** `LinkChannelContacts::historySettled` uses a **600 s** quiet window for a Coexistence channel without the explicit done signal (`COEX_QUIET_SECONDS`) — Meta's phases can be minutes apart and the Bridge's 90 s would settle mid-gap, pop the summary card, then re-arm on the next phase. The final-phase 100 % signal still shortens it to the usual 10 s.

### Where the flag lives
`isCoexistence()` = `isCloudApi() && provider_config['coexistence']`. Set at sign-up (the checkbox / the `FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING` event / `is_on_biz_app` on the discovered number) and **kept honest by `ChannelInfoSync`** from Meta's `is_on_biz_app` (on connect, "Sync from Meta", and the hourly `whatsapp:sync-quality`) — so a number the owner later disconnects from the phone, or signs in to the app with afterwards, is tracked either way. `syncsHistory()` (`isBridge() || isCoexistence()`) is what the history-sync banner, the sync-summary card and `LinkChannelContacts` gate on. `onboarding()` (`embedded_signup` / `partner_sharing`, older channels carry no marker and read as partner-shared) is shown on the Connection tab with a **Coexistence** chip.

## Meta-side prerequisites (not code)
1. The **WhatsApp app** (`WHATSAPP_APP_ID` / `WHATSAPP_APP_SECRET`, falling back to `META_APP_ID` / `META_APP_SECRET` — it is the one Meta app shared with Facebook connect, Lead Ads and Messenger) registered as a **Tech Provider**; **Business Verification** done; **Advanced Access** for `whatsapp_business_management` + `whatsapp_business_messaging` (+ `public_profile`). With Standard Access the popup works for people with a role on the app and fails for every real customer — test with an account that has no role.
2. A **Facebook Login for Business** configuration created in the app's **Embedded Signup builder** (v4) → its id is `WHATSAPP_ES_CONFIG_ID`.
3. **Webhook fields** are subscribed **at the app level** (App dashboard → WhatsApp → Configuration): keep `messages` + the existing ones, add **`history`, `smb_app_state_sync`, `smb_message_echoes`, `account_update`**. `POST /{waba_id}/subscribed_apps` only attaches the app to the WABA — it does not pick fields.
4. The site host in **Allowed Domains** (https, exact match). The SDK loads from `https://connect.facebook.net`.

## Limits of a Coexistence number (Meta's rules, surfaced on the Connection tab)
Fixed **20 messages/second**; the **24-hour service window still applies to API sends** (the phone is unrestricted); **no groups, broadcast lists, calls, catalog / status / marketing-message tools through the API** on that number; group chats are never synced; history must be requested within 24 h of onboarding or the customer has to onboard again; the **Deregister API cannot be used** on a Coexistence number (the owner disconnects from the app, we get `PARTNER_REMOVED`); a phone offline for 14 days disconnects.

## Related files
- [src/Whatsapp/Services/EmbeddedSignupClient.php](/src/Whatsapp/Services/EmbeddedSignupClient.php) — code exchange, token debug, WABA phone-number discovery (pre-channel Graph calls).
- [app/Http/Requests/Manage/Whatsapp/EmbeddedSignupRequest.php](/app/Http/Requests/Manage/Whatsapp/EmbeddedSignupRequest.php) · [ChannelsController::storeEmbeddedSignup](/app/Http/Controllers/Manage/Whatsapp/ChannelsController.php).
- [resources/js/composables/useFacebookSdk.js](/resources/js/composables/useFacebookSdk.js) — SDK loader, `launchEmbeddedSignup`, the `WA_EMBEDDED_SIGNUP` message listener · [Channels/Partials/ChannelFormModal.vue](/resources/js/Pages/Manage/Messages/Channels/Partials/ChannelFormModal.vue) — the two-path Cloud branch · [ConnectionTab.vue](/resources/js/Pages/Manage/Messages/Channels/Partials/Tabs/ConnectionTab.vue) — "Connected via" + the Coexistence chip.
- [src/Whatsapp/Drivers/CloudApiDriver.php](/src/Whatsapp/Drivers/CloudApiDriver.php) — `FIELD_*` / `PACED_FIELDS`, `pacedFields`, `normalizeEcho`, `normalizeHistoryMessage`, `parseHistorySet` / `parseHistoryStatus` / `parseContactUpdates` / `parseMessageMutations`, `requestSmbAppData`, `fetchPhoneInfo` (`is_on_biz_app`).
- [app/Http/Controllers/Webhooks/WhatsAppWebhookController.php](/app/Http/Controllers/Webhooks/WhatsAppWebhookController.php) — lane routing + the multi-change relay lookup.
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — `processHistorySet` (placeholder upgrade), `processHistoryStatus` (declined + contacts dispatch), `processAccountUpdates` (PARTNER_REMOVED), the `considerAiCallButton` guard · [app/Jobs/Whatsapp/RequestCoexistenceSync.php](/app/Jobs/Whatsapp/RequestCoexistenceSync.php) · [app/Jobs/Whatsapp/LinkChannelContacts.php](/app/Jobs/Whatsapp/LinkChannelContacts.php) (`COEX_QUIET_SECONDS`).
- [src/Whatsapp/Repositories/WhatsappRepository.php](/src/Whatsapp/Repositories/WhatsappRepository.php) — `mergeChannelProviderConfig`, `upgradeMediaPlaceholder`, `recordInbound` (`status` for outbound history rows, `Phone: ` preview) · [src/Whatsapp/Services/ChannelInfoSync.php](/src/Whatsapp/Services/ChannelInfoSync.php) — the `is_on_biz_app` flag · [src/Whatsapp/Support/MessagePresenter.php](/src/Whatsapp/Support/MessagePresenter.php) — `phone` (the badge) · [src/Whatsapp/WhatsappChannel.php](/src/Whatsapp/WhatsappChannel.php) — `isCoexistence`, `syncsHistory`, `onboarding`, `ONBOARDING_*`, `COEXISTENCE_SYNC_*` · [src/Whatsapp/WhatsappMessage.php](/src/Whatsapp/WhatsappMessage.php) — `META_SOURCE`, `SOURCE_SMB_*`, `META_MEDIA_PLACEHOLDER`, `isPhoneEcho`.
- Config: [config/whatsapp.php](/config/whatsapp.php) `cloud.embedded_signup_config_id` (`WHATSAPP_ES_CONFIG_ID`).
- Tests: `tests/Feature/Whatsapp/EmbeddedSignupTest.php`, `CoexistenceEchoTest.php`, `CoexistenceHistoryTest.php`, `CoexistenceWebhookLaneTest.php`, `CoexistenceSyncRequestTest.php`.
