# WhatsApp · Broadcast (Manage)

**Portal:** Manage · **Status:** **Phases 0–4 shipped** (consent foundation · MVP send pipeline · real-time safety hardening · reach + richness · engine reliability + QR/Bridge broadcasting) · **Nav:** Messages → **Broadcasts**, a main tab of the hub strip ([`Components/Messages/MessagesTabs.vue`](/resources/js/Components/Messages/MessagesTabs.vue)) whose pills are **Campaigns / Templates / Segments / Settings** (the module-specific `BroadcastNav.vue` was replaced by the shared strip on 2026-07-27, when Templates joined it; the standalone "Segments" sidebar item had already gone on 2026-07-12) · **Routes:** `manage.messages.broadcasts.*` (+ `audience-estimate`, `audience-csv`, `recipients.export`, `settings`) · `manage.messages.segments.*` · `manage.messages.contacts.{consent,import}`

> This is the **design spec + build tracker** for sending **Marketing / Utility / Authentication template messages in bulk**. The business was previously **restricted by Meta for AI automation**, so ban-safety is the overriding design driver. Read it before building any broadcast code.

## Hard constraints
- **Cloud broadcasts = approved templates only**, for the channel + language. Free-form on Cloud is structurally impossible (every Cloud send is `TYPE_TEMPLATE`), and the stored body variables are validated against the approved `{{n}}` count at compose AND build time (a mismatch would 132000-reject every send).
- **QR (Bridge) broadcasting exists but is explicitly HIGH-RISK and opt-in per broadcast** (Phase 4, 2026-07-12 — a deliberate product decision reversing the earlier hard block). It sends **free-form text/image** (never templates), is always **forced to `CATEGORY_MARKETING`** so the STOP ladder applies in full, and is gated at every layer by BOTH the config kill switch `whatsapp.broadcast.allow_bridge` (`WHATSAPP_BROADCAST_ALLOW_BRIDGE`) AND a per-broadcast admin **risk acknowledgement** (`risk_acknowledged_at`/`_by` — the compose modal shows a red "use at your own risk" panel whose checkbox is mandatory). Its pacer is far stricter than Cloud's: `bridge_per_minute` ceiling (default 4), `bridge_daily_cap` (default 150), a 09:00–22:00 waking-hours window, **jittered gaps** (0.6–2.2× base + occasional long pauses — a fixed interval is itself a bot signal), and a typing-presence simulation before each send. The QR session must be CONNECTED or the broadcast auto-pauses (`PAUSE_BRIDGE_OFFLINE`).
- **Consent policy (2026-07-12, product decision): DEFAULT OPT-IN.** `broadcast.require_consent_marketing` now defaults **false** — a broadcast reaches everyone in the audience EXCEPT explicit opt-outs (STOP / unsubscribe button), admin-blocked contacts, invalid numbers, Meta marketing cooldowns and frequency-capped contacts. Setting the env back to true restores the strict opted-in-only mode. The consent LEDGER (Phase 0) is unchanged and still authoritative for opt-outs.
- Per-recipient **pacing** (no bursts, per-CHANNEL budget split across concurrent campaigns), **quality auto-pause**, per-contact **frequency caps**, honour **STOP/unsubscribe**, **dedupe**, skip invalid/non-WhatsApp numbers, respect the **messaging-limit tier** + daily business-initiated cap.

## Architecture decision (2026-06-30) — HYBRID
Three approaches were evaluated (lightweight tag / full campaign platform / CRM-integrated); the chosen path ships the **lightweight, structurally-safe core as the MVP** and grafts the full **per-category consent ledger** + **real-time auto-pause** where they matter most. Confirmed decisions:
1. **Consent = a per-category `whatsapp_consents` ledger + `proof` JSON** (not a single boolean) — distinguishes Marketing opt-in from Utility/Auth and gives the Meta audit trail a restricted business needs.
2. **Audience = WhatsApp tags only** for the MVP (CRM / lead-funnel segments → Phase 3).
3. **Header media + buttons in the MVP** → `CloudApiDriver::sendTemplate` must be extended from body-only to a full `components` array (header text/media + body + URL/quick-reply buttons) in **Phase 1**.
4. **Slow, capped pacing** — a single-process `redis-broadcast` Horizon lane (`maxProcesses=1`) + a per-channel Redis token bucket; the messaging tier is auto-synced by `whatsapp:sync-quality`. Bursting is physically impossible.

## Phased plan
- **Phase 0 — Consent foundation. ✅ SHIPPED.** The durable, auditable opt-out a restricted business must have **before** the first marketing send (see below).
- **Phase 1 — MVP (the smallest SAFE broadcast). ✅ SHIPPED** (see *Phase 1 — what shipped* below).
- **Phase 2 — Real-time safety hardening. ✅ SHIPPED** (see *Phase 2 — what shipped* below).
- **Phase 3 — Reach + richness. ✅ SHIPPED** (see *Phase 3 — what shipped* below).
- **Phase 4 — Engine reliability + default opt-in + QR/Bridge broadcasting. ✅ SHIPPED** (2026-07-12 — see *Phase 4 — what shipped* below).

## Phase 0 — what shipped (consent foundation)
- **`whatsapp_consents` ledger** ([WhatsappConsent](/src/Whatsapp/WhatsappConsent.php)) — one current row per **(contact, category)** with `state` (OPTED_IN / OPTED_OUT / PENDING), `source` provenance, a **`proof` JSON** evidence blob (raw_message / admin_id / …), and `opted_in_at` / `opted_out_at`. `CATEGORY_*` mirror `WhatsappTemplate::CATEGORY_*` (Marketing=1 / Utility=2 / Auth=3) so a broadcast's category maps 1:1 to the consent it requires. Current-state (not soft-deleted), `UNIQUE(contact_id, category)`.
- **Inbound STOP → durable opt-out.** [ProcessInboundWhatsAppWebhook::considerOptOut](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) runs on **every** inbound (even AI-off channels): a TEXT exactly matching a `config('whatsapp.broadcast.opt_out_keywords')` stop word (the **unsubscribe subset** — NOT the "I want a human" handoff words) writes **OPTED_OUT across every category** via `WhatsappConsentRepository::optOutAll`. The same word may also trigger the AI handoff — both are correct.
- **Admin opt-in toggle.** [ContactsController::setConsent](/app/Http/Controllers/Manage/Whatsapp/ContactsController.php) (`POST contacts/{id}/consent`, [SetConsentRequest](/app/Http/Requests/Manage/Whatsapp/Contacts/SetConsentRequest.php)) sets a contact's marketing consent (opt-in / opt-out / clear, `source = ADMIN`, `proof = {admin_id}`). The inbox conversation header shows a **Marketing consent chip** (✓ opted-in / Opted out / Opt-in) — optimistic silent XHR. `InboxController` exposes `marketing_consent` on the open conversation.
- **Frequency-cap fast path.** `whatsapp_contacts.last_broadcast_at` + `broadcasts_sent_count` (the Phase-1 audience builder reads these to skip a contact messaged within the cooldown window; the authoritative history will live on `whatsapp_broadcast_recipients`).

## Phase 1 — what shipped (the MVP send pipeline + §14 UI)
- **Tables / models.** `whatsapp_broadcasts` ([WhatsappBroadcast](/src/Whatsapp/WhatsappBroadcast.php), key model: STATUSES Draft→Failed, PAUSE_* reasons, channel/template/recipients relations) + child `whatsapp_broadcast_recipients` ([WhatsappBroadcastRecipient](/src/Whatsapp/WhatsappBroadcastRecipient.php), no uuid, STATUS_* / SKIP_* consts, `message_id` FK, `UNIQUE(broadcast_id, contact_id)`).
- **Repository** ([WhatsappBroadcastRepository](/src/Whatsapp/Repositories/WhatsappBroadcastRepository.php)) — `create`/`update` (Cloud + approved-template **hard gate**, category denormalised), `materializeRecipients` (tag → contacts, dedupe, skip blocked/opted-out/not-opted-in/invalid/frequency-capped — nothing silently dropped), `prepareHeaderMedia` (upload header media to Graph **once**), `sendRecipient` (live re-check → claim QUEUED → `createOutbound` TYPE_TEMPLATE → link `message_id` → bump counters + contact frequency stamps), `setStatus` transitions, `templateParams`.
- **Send pipeline.** [BuildBroadcastAudience](/app/Jobs/Whatsapp/BuildBroadcastAudience.php) (prepare media + materialize + → SENDING) then [DispatchBroadcastBatch](/app/Jobs/Whatsapp/DispatchBroadcastBatch.php) — the **pacer**: every tick re-checks all hard gates (Cloud, quality-RED, daily cap, quiet hours, still SENDING), pulls `throttle_per_minute` PENDING, sends each via the EXISTING `createOutbound → SendWhatsAppMessage → CloudApiDriver::sendTemplate` path (delivery webhooks roll up free), spreads sends across the minute, re-dispatches itself; `WithoutOverlapping` per broadcast. Both on the **capped, single-process** `redis-broadcast` Horizon lane ([config/queue.php](/config/queue.php) + [config/horizon.php](/config/horizon.php) `supervisor-broadcast`, `maxProcesses=1`) so bursting is structurally impossible. **`supervisor-broadcast` is activated in `config/horizon.php` `environments.production` + `environments.local`** (added 2026-07-01 — it was previously defined but unlisted, so the `broadcast` queue never drained; verify a `horizon:work redis-broadcast` worker is running after a `horizon:terminate`). [whatsapp:run-broadcasts](/app/Console/Commands/RunWhatsappBroadcasts.php) (scheduled every minute) starts due-scheduled broadcasts + backstops the quality-RED pause; `whatsapp:sync-quality` is now scheduled hourly.
- **Template components.** [CloudApiDriver::sendTemplate](/src/Whatsapp/Drivers/CloudApiDriver.php) extended from body-only to a full components array — header (text variable / image / video / document by uploaded media id), body positional text, and dynamic URL / quick-reply button params (`buildTemplateComponents`) + `uploadTemplateHeaderMedia`.
- **UI (§14).** [BroadcastsController](/app/Http/Controllers/Manage/Whatsapp/BroadcastsController.php) (`ResolvesListQuery` + `ResolvesBackUrl`: index/show/store/update/start/pause/resume/cancel/destroy + `templates` JSON + `uploadHeaderMedia`) + [BroadcastQueryRequest](/app/Http/Requests/Manage/Whatsapp/Broadcasts/BroadcastQueryRequest.php) / [StoreRequest](/app/Http/Requests/Manage/Whatsapp/Broadcasts/StoreRequest.php) (Cloud + approved-template validated) / `UpdateRequest`. Frontend: [Broadcasts/Index.vue](/resources/js/Pages/Manage/Messages/Broadcasts/Index.vue) (DataTable + filters + lifecycle row actions), [Broadcasts/Show.vue](/resources/js/Pages/Manage/Messages/Broadcasts/Show.vue) (stats + Recipients tab + Start/Pause/Resume/Cancel/Delete), [Partials/BroadcastFormModal.vue](/resources/js/Pages/Manage/Messages/Broadcasts/Partials/BroadcastFormModal.vue) (compose — renders the right header/body/button inputs by parsing the picked template's `components`; header-media upload; tag audience + reach; schedule + throttle). Nav: "Broadcasts" in all three WhatsApp groups.
- **Settings.** `broadcast.*` guards (throttle_per_minute / daily_business_initiated_cap / quiet_hours / auto_pause_on_quality_red / contact_min_interval_hours / require_consent_marketing) in `WhatsappSetting::CONFIG_PATHS` + a "Broadcast guards" section on the Settings page.

## Phase 2 — what shipped (real-time safety hardening + ops)
- **Real-time webhook auto-pause.** Two new driver parsers + DTOs — [CloudApiDriver::parseTemplateStatusUpdates](/src/Whatsapp/Drivers/CloudApiDriver.php) → [TemplateStatusUpdate](/src/Whatsapp/Drivers/Data/TemplateStatusUpdate.php) and `parseAccountUpdates` → [AccountUpdate](/src/Whatsapp/Drivers/Data/AccountUpdate.php) (BridgeDriver returns `[]` for both; the Bridge has no Meta account/template concepts) — feed two new loops in [ProcessInboundWhatsAppWebhook::handle](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php). A `message_template_status_update` = PAUSED/DISABLED updates the local template ([WhatsappRepository::updateTemplateStatus](/src/Whatsapp/Repositories/WhatsappRepository.php)) and pauses every in-flight broadcast on that template (`WhatsappBroadcastRepository::pauseActiveForTemplate`, reason `PAUSE_TEMPLATE_PAUSED`, gated by `broadcast.auto_pause_on_template_paused`); an `account_update` quality-FLAGGED / restriction writes channel quality (reusing `updateChannelQuality`, the same writer the poll uses) and pauses broadcasts on that channel (`pauseActiveForChannel`, reason `PAUSE_QUALITY_RED`). **Trap handled:** these events are keyed at the **WABA** level (`entry[].id`), not `phone_number_id`, so the job resolves channels by decrypted `provider_config['business_account_id']` (`channelsForWaba`, memoised) — the existing `resolveChannel` would never match them. On a WABA hosting **several numbers**, a WABA-wide event (template status, account restriction) is applied to **every** matching channel, while a per-number `phone_number_quality_update` is narrowed to the **one** channel whose `phone_e164` matches the event's `display_phone_number` (`resolveChannelByPhone`) — so a healthy sibling number is never wrongly paused and the flagged one is never missed (unresolvable numbers are skipped + logged, never guessed). The poll (`whatsapp:run-broadcasts` / `whatsapp:sync-quality`) stays as the backstop.
- **Recipient error persistence.** [StatusUpdate](/src/Whatsapp/Drivers/Data/StatusUpdate.php) now carries `errorCode`/`errorTitle` (from `status.errors[0]`); `WhatsappRepository::updateStatus` stamps them onto `message.meta.error` when a send settles FAILED, and `WhatsappBroadcastRepository::recordRecipientError` flips the linked recipient (looked up by `message_id`) to FAILED + writes `error_code`/`error_title` (respecting the monotonic ladder — a delivered message is never re-failed).
- **Counter reconciler.** [whatsapp:reconcile-broadcasts](/app/Console/Commands/ReconcileWhatsappBroadcasts.php) (scheduled every 5 min) recomputes `total_recipients` / `skipped_count` / `queued_count` from the recipients table (the source of truth — `queued_count` = rows with a `message_id`, drift-free) via `recountCounters`, and **completes** a SENDING broadcast whose pacer chain died and has no PENDING left (`hasPendingRecipients`). It never starts/quality-pauses — that stays `whatsapp:run-broadcasts`' job (no overlap).
- **CSV contact import.** [ContactsImport](/app/Imports/Whatsapp/ContactsImport.php) (maatwebsite `ToCollection` + `WithHeadingRow` — the repo's first Import class) → [WhatsappContactRepository::import](/src/Whatsapp/Repositories/WhatsappContactRepository.php): normalise each phone (E.164 dedupe), upsert the contact, attach the chosen tags, and — **only when the admin attests documented consent** (off by default) — record a `SOURCE_IMPORT` marketing opt-in. [ContactsController::import](/app/Http/Controllers/Manage/Whatsapp/ContactsController.php) (`POST contacts/import`, [ImportRequest](/app/Http/Requests/Manage/Whatsapp/Contacts/ImportRequest.php)); UI = an **Import contacts** modal ([Partials/ImportContactsModal.vue](/resources/js/Pages/Manage/Messages/Broadcasts/Partials/ImportContactsModal.vue)) on the Broadcasts index.
- **Delivery-report export.** [BroadcastRecipientsExport](/app/Exports/Whatsapp/BroadcastRecipientsExport.php) (one row per recipient: name / phone / lifecycle status / skip reason / **delivery** state from the linked message / error code / error / sent_at) via the `ExportsResource` trait; `GET broadcasts/{id}/recipients/export?format=xlsx|csv` ([BroadcastsController::exportRecipients](/app/Http/Controllers/Manage/Whatsapp/BroadcastsController.php)); download links on the Show page's Recipients tab.

## Phase 3 — what shipped (reach + richness)
- **Audience sources** — one [AudienceResolver](/src/Whatsapp/Services/AudienceResolver.php) service turns a broadcast's `audience` json into a `WhatsappContact` query, branching on `source`: `tags` (default, the legacy selector), `segment` (a saved segment's stored tags), `lead_funnel` (`funnel_id` + lead `statuses[]` → leads on that EventFunnel at those pipeline stages), `members` (active `MemberSubscription`), and **`csv`** (added 2026-07-09 — see below). `materializeRecipients` now calls the resolver (the skip-filter loop is unchanged). **The CRM sources resolve leads to EXISTING WhatsApp contacts by normalised phone — never creating cold contacts — so a marketing blast can only reach people already in the WhatsApp audience (and, with consent required, only the opted-in subset).** Two phone formats are reconciled: `user_profiles.phone` (digits) → `PhoneNormalizer::toE164` to match `whatsapp_contacts.phone_e164`, preferring `lead_enrichments.phone_e164` when present.
- **Saved segments** — a new §14 module: `whatsapp_segments` ([WhatsappSegment](/src/Whatsapp/WhatsappSegment.php), key model, `filters` json = `{tag_ids, match}`), [WhatsappSegmentRepository](/src/Whatsapp/Repositories/WhatsappSegmentRepository.php), [SegmentsController](/app/Http/Controllers/Manage/Whatsapp/SegmentsController.php) (modal CRUD like Tags) + Store/Update requests, [Segments/Index.vue](/resources/js/Pages/Manage/Messages/Segments/Index.vue) + [Partials/SegmentFormModal.vue](/resources/js/Pages/Manage/Messages/Segments/Partials/SegmentFormModal.vue), routes `manage.messages.segments.*`, nav "Segments" in all three suites.
- **Compose UI** — [BroadcastFormModal](/resources/js/Pages/Manage/Messages/Broadcasts/Partials/BroadcastFormModal.vue) gained an **audience-source switch** (Tags / Segment / Lead funnel / Members) with the right picker per source and a live reach estimate (tags summed locally; CRM/segment via the debounced `POST broadcasts/audience-estimate` → `AudienceResolver::count`).
- **Per-recipient personalization** — auto first-name: `variables.personalize = { index, fallback }` on the broadcast; `WhatsappContact::first_name` (first word of the name); `templateParams($broadcast, $contact)` swaps the chosen 1-based body variable with the contact's first name (or the fallback when blank — Meta rejects empty body vars), frozen per recipient at send. Header media stays shared (can't personalise a single Graph media id).
- **Landing opt-in** — the public lead-capture form ([LeadCaptureForm.vue](/resources/js/Components/LeadCaptureForm.vue)) now has a **default-checked** "keep me updated on WhatsApp" box; on register, [RegisterLeadAction::recordWhatsappConsent](/app/Actions/RegisterLeadAction.php) (best-effort) calls `WhatsappContactRepository::recordLandingOptIn` → find/create the contact (linked to the account), and write a `CATEGORY_MARKETING` / `SOURCE_LANDING_FORM` consent with an IP/UA/URL/timestamp proof trail. `wa_opt_in` validated in [RegisterRequest](/app/Http/Requests/Main/RegisterRequest.php). See the [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) handbook.
- **Settings** — `broadcast.auto_pause_on_template_paused` (default on) added to config + `WhatsappSetting::CONFIG_PATHS` + the Settings "Broadcast guards" toggles.

## CSV one-off audience (2026-07-09)
The compose modal's audience gained a **CSV upload** source: the admin uploads a `name,phone` CSV (a **Download example CSV** button shows the exact format), the file is parsed + cleaned server-side (`BroadcastsController::parseAudienceCsv`, `POST broadcasts/audience-csv`) and previewed — valid count, duplicates removed, and each invalid row with its line + reason (incl. Excel scientific-notation detection). **Phone cleaning** runs through `PhoneNormalizer::toE164`, which now also repairs the common **country-code + trunk-zero paste error** (`+600108685352` / `600108685352` → `+60108685352`) — so `0108…`, `+60108…`, `60108…`, `00 60108…` and `+600108…` all normalise to one contact. On save, `WhatsappBroadcastRepository::prepareCsvAudience` re-normalises + dedupes the rows server-side (posted rows are never trusted), **find-or-creates the contacts** (name backfilled) and **auto opt-ins them to marketing** (`SOURCE_IMPORT`, proof = broadcast name + admin id — the chosen policy for CSV uploads) — **except contacts who explicitly opted out: a STOP is durable and never overridden; they stay opted-out and are skipped at send** (`SKIP_OPTED_OUT`). The stored audience is a one-off list `{source:'csv', phones:[E.164…]}` (no tag is created); `AudienceResolver` resolves it back with a `whereIn(phone_e164)`. Capped at 5,000 rows per broadcast; the reach hint = the cleaned row count.

## Phase 4 — what shipped (2026-07-12: engine reliability + default opt-in + QR/Bridge broadcasting)
An adversarial audit of the engine (plus 2026 Meta/Baileys research) drove a hardening pass and the QR channel feature:

**Engine reliability (the "10k-send-safe" fixes)**
- **Pacer generations.** `whatsapp_broadcasts.dispatch_token` — every `DispatchBroadcastBatch` tick carries its chain's token and **no-ops when stale**, so pause→resume (or a revival) can never leave two live chains double-sending. `WhatsappBroadcastRepository::beginDispatch` mints the token (controller `resume`, build job, reconciler revival all use it).
- **Heartbeat + revival.** Each live tick stamps `last_tick_at` (`touchTick`); [whatsapp:reconcile-broadcasts](/app/Console/Commands/ReconcileWhatsappBroadcasts.php) revives a SENDING broadcast silent for >20 min with a fresh generation — a worker crash / dropped delayed job no longer stalls a campaign forever.
- **Lock expiry.** The pacer's `WithoutOverlapping` now has `expireAfter(360)` — a SIGKILLed worker can no longer brick the chain with an eternal lock (which previously also swallowed Resume silently).
- **Atomic claim + crash recovery.** `sendRecipient` claims PENDING→QUEUED via a **conditional UPDATE** (exactly one winner); recipients stuck QUEUED with no `message_id` (worker died between claim and write) are **requeued** by `requeueStaleClaims` (pacer `completeOrWait` + reconciler), and a broadcast is only COMPLETED once no unresolved claims remain — crash victims are never silently swallowed.
- **BUILDING can fail.** `BuildBroadcastAudience::failed()` marks a crashed build **FAILED** (`FAIL_BUILD`); the reconciler also fails a BUILDING row older than 30 min. The BUILDING→SENDING flip is a **CAS** (`transitionStatus`) so a broadcast cancelled mid-build is never resurrected.
- **Per-channel pace.** The tick budget is split across every broadcast concurrently SENDING on the same channel (`perMinute`) — two campaigns share one number's pace instead of stacking it.
- **Empty-tag audience fix.** `AudienceResolver::fromTags` with no/unresolvable tags now returns **zero contacts** (it used to fall through to EVERY contact in the DB while the modal showed "≈ 0"); `StoreRequest` requires ≥1 tag for the tags source.
- **Template-variable validation** at compose (`StoreRequest::validateCloudTemplate`) and build (`guardTemplateVariables`) — counts a personalised slot as filled.

**Meta error-code routing** ([BroadcastErrorRouter](/src/Whatsapp/Support/BroadcastErrorRouter.php))
- **131049 / 130472** (per-user marketing limit / holdout experiment) → stamp `whatsapp_contacts.marketing_cooldown_until` (+`broadcast.marketing_cooldown_hours`, default 48h, never <24h — Meta warns fast retries get ALL delivery to that user blocked); skipped at build+send as `SKIP_MARKETING_COOLDOWN`.
- **131048 / 131042** (spam rate limit / billing) → `pauseActiveForChannel` (reasons `PAUSE_SPAM_RATE` / `PAUSE_BILLING`) + an `OpsAlertService` alert.
- **130429 / 131000 / 131016** classified retryable; everything else recorded and moved past. Routing runs from the status-webhook path AND from `SendWhatsAppMessage::recordBroadcastOutcome` — a **synchronous** Graph/bridge rejection now flips the recipient FAILED too (previously only webhook failures did).

**Opt-out hardening** ([ProcessInboundWhatsAppWebhook](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php))
- `considerOptOut` now also accepts **TYPE_INTERACTIVE** (a tapped "Stop promotions"-style button — body/title/id all checked).
- `matchesOptOutKeyword` is forgiving where it's safe: punctuation/`please`-prefix stripped, word-bounded containment for ≥5-char Latin keywords ("unsubscribe me" opts out), plain containment for multi-char CJK ("请退订" opts out), while ambiguous short words stay exact-only ("stop by tomorrow?" does NOT).

**Settings + Segments consolidated into the Broadcasts area (2026-07-12).** The Broadcasts pages now share a sub-nav ([Components/Messages/MessagesTabs.vue](/resources/js/Components/Messages/MessagesTabs.vue) — this replaced the module-specific `BroadcastNav.vue` on 2026-07-27, when Templates joined the strip and the whole module collapsed to one sidebar entry — **Campaigns / Segments / Settings**, preserving `?suite=`), so the standalone "Segments" sidebar item was removed (Segments is unchanged otherwise). **All broadcast guards are now UI-adjustable** on the new **Broadcasts → Settings** tab ([Broadcasts/Settings.vue](/resources/js/Pages/Manage/Messages/Broadcasts/Settings.vue) ← [BroadcastsController::settings/updateSettings](/app/Http/Controllers/Manage/Whatsapp/BroadcastsController.php) ← [Broadcasts/SettingsRequest](/app/Http/Requests/Manage/Whatsapp/Broadcasts/SettingsRequest.php)), including the Phase-4 additions (`marketing_cooldown_hours` + the QR/Bridge controls `allow_bridge` / `bridge_per_minute` / `bridge_daily_cap` / `bridge_window_start`/`_end` / `bridge_typing_simulation`) — all added to `WhatsappSetting::CONFIG_PATHS`, with `.env` remaining the fallback. The broadcast guards were **moved off** the general WhatsApp → Settings page (now AI-guards-only). Both forms write the same encrypted `whatsapp_settings` row but each saves ONLY its own subtree via `WhatsappSetting::aiPaths()` / `broadcastPaths()` passed to `WhatsappSettingRepository::save($input, $onlyPaths)`, so neither clobbers the other (verified: split-save roundtrip + `apply()` merge of the `allow_bridge` kill switch).

**QR (Bridge) broadcasting** — see *Hard constraints* above for the full gate list. Data model: `template_id` now nullable + `content_type` (`CONTENT_TEMPLATE/TEXT/IMAGE`), `body` (free-form, `{{name}}` personalised per recipient via `renderBridgeBody` — deliberate per-recipient variance), `media_id` (image via the existing `MediaService` upload endpoint), `risk_acknowledged_at`/`_by`. Repository `prepareBridgeContent` forces MARKETING + clamps the throttle; `createBridgeOutbound` emits TYPE_TEXT/TYPE_IMAGE through the normal pipeline; `DispatchBroadcastBatch::sendBridgeBatch` schedules jittered sends and `SendWhatsAppMessage::simulateTypingForBroadcast` shows composing + a 2–5s pause before each. UI: channel picker lists Bridge channels (flagged "QR ⚠ high risk"), compose shows the red risk panel + mandatory checkbox, Show/Index badge QR broadcasts and show the body; Show auto-polls every 8s while BUILDING/SENDING and translates every `pause_reason` (incl. the new `spam_rate`/`billing`/`bridge_offline`/`bridge_disabled`/`build_failed`) into plain English.

## Planned data model (Phases 1+)
| Table | Role | Key columns |
|---|---|---|
| **whatsapp_broadcasts** | campaign | channel_id (Cloud-only), template_id, language, category (denormalised), status const, audience json (tag_ids + match mode, frozen), variables/variable_map json, scheduled_at, pause_reason, throttle/daily_cap overrides, counters |
| **whatsapp_broadcast_recipients** | per-recipient ledger (child, no uuid) | broadcast_id, contact_id, **message_id** (FK → whatsapp_messages — delivery webhooks roll up through it), phone_e164 snapshot, variables json, status const, **skip_reason** const, error_code/error_title, `UNIQUE(broadcast_id, contact_id)` |
| **whatsapp_consents** | per-category consent (Phase 0 ✅) | see above |
| `whatsapp_contacts` (ALTER, Phase 0 ✅) | frequency-cap fast path | last_broadcast_at, broadcasts_sent_count |
| `WhatsappSetting` CONFIG_PATHS (Phase 1) | guards | broadcast.throttle_per_minute / daily_business_initiated_cap / quiet_hours / auto_pause_on_quality / contact_min_interval_hours / require_consent_marketing / tier_caps |

## Safety controls (full set, by phase)
Cloud-only 4-layer gate (1) · template approved-only (1) · capped single-process pacing lane + per-channel token bucket (1) · per-category consent gate, re-checked at send (0 ledger / 1 enforcement) · STOP→opt-out (0 ✅) · per-contact frequency cap (0 columns / 1 enforcement) · messaging-tier + daily business-initiated cap (1) · dedupe via UNIQUE + E.164 (1) · invalid/non-WhatsApp skip (1) · quiet-hours (1) · blocked-contact skip (already enforced) · quality auto-pause: poll (1) **+ real-time `account_update` webhook (2 ✅)** · template PAUSED/DISABLED auto-pause: real-time `message_template_status_update` webhook (2 ✅) · recipient `error_code`/`error_title` from status webhooks (2 ✅) · CRM audience sources resolve to EXISTING consented contacts only — never cold-create (3 ✅).

## Related files (Phase 0 + the Phase-4 schema)
- [src/Whatsapp/WhatsappConsent.php](/src/Whatsapp/WhatsappConsent.php) · [src/Whatsapp/Repositories/WhatsappConsentRepository.php](/src/Whatsapp/Repositories/WhatsappConsentRepository.php)
- [src/Whatsapp/WhatsappContact.php](/src/Whatsapp/WhatsappContact.php) — `consents()` / `consentState()` / `isOptedIn()` / `isOptedOut()` + the broadcast-tracking columns.
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — `considerOptOut` / `isOptOutMessage`.
- [app/Http/Controllers/Manage/Whatsapp/ContactsController.php](/app/Http/Controllers/Manage/Whatsapp/ContactsController.php) — `setConsent` · [SetConsentRequest](/app/Http/Requests/Manage/Whatsapp/Contacts/SetConsentRequest.php).
- [app/Http/Controllers/Manage/Whatsapp/InboxController.php](/app/Http/Controllers/Manage/Whatsapp/InboxController.php) — `marketing_consent` prop · [resources/js/Pages/Manage/Messages/Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) — the consent chip.
- [config/whatsapp.php](/config/whatsapp.php) — `broadcast.opt_out_keywords`.
- [database/migrations/2026_06_30_000001_create_whatsapp_consents_table.php](/database/migrations/2026_06_30_000001_create_whatsapp_consents_table.php) · [_000002_add_broadcast_tracking_to_whatsapp_contacts_table.php](/database/migrations/2026_06_30_000002_add_broadcast_tracking_to_whatsapp_contacts_table.php)
- **Phase 4** — [database/migrations/2026_07_12_100001_add_bridge_and_pacer_columns_to_whatsapp_broadcasts_table.php](/database/migrations/2026_07_12_100001_add_bridge_and_pacer_columns_to_whatsapp_broadcasts_table.php) (nullable `template_id` + `content_type` / `body` / `media_id`, `risk_acknowledged_at`/`_by`, and the pacer's `dispatch_token` / `last_tick_at`) · [2026_07_12_100002_add_marketing_cooldown_to_whatsapp_contacts_table.php](/database/migrations/2026_07_12_100002_add_marketing_cooldown_to_whatsapp_contacts_table.php) (`marketing_cooldown_until` — the 131049/130472 error-routing cooldown).

## Prerequisites (Phase 1+)
A **verified** Meta Business portfolio + an approved Cloud channel; approved templates synced (`whatsapp:sync-templates`); `whatsapp:sync-quality` on a schedule (quality auto-pause); a `redis-broadcast` queue connection + a capped `supervisor-broadcast` Horizon lane on the prod VM (ties into the deferred Forge deployment). The `supervisor-broadcast` lane is **activated** in `config/horizon.php` `environments.{production,local}` (`maxProcesses: 1`) — after any deploy, `php artisan horizon:terminate` and confirm a **`horizon:work redis-broadcast`** worker is running (the `broadcast` queue won't drain without it).
