# WhatsApp · CTA Links (Manage)

**Portal:** Manage · **Routes:** `manage.messages.cta-links.*` · **Nav:** Messages → AI Automation → CTA Links

## What it does
An admin-authored **CTA link**: a shareable **`wa.me` link with a pre-filled default text** — pasted into a Zoom webinar chat, an ad, an email or a bio link. When a customer taps it, WhatsApp opens with the text pre-filled; the moment they hit send, the inbound pipeline **captures them as a CRM lead**, records the **CTA touch** on the contact (so the lead's detail page shows exactly which CTA brought them in), and can optionally **start a flow** (the scripted drip + AI takeover engine) as the automated reply.

**Two link modes** (`source`): a **connected channel** (a number the system owns → replies are captured), or a **plain phone number** (a wa.me generator only — no capture, for numbers we don't connect). Every link also gets a **self-hosted short link** `/{APP}/wa/{slug}` that 302-redirects to the wa.me URL and tallies a **click count**. And when a CTA message lands **while a Zoom webinar is live** (matched purely by time), the capture is **tagged to that session** — surfacing on a per-session **CTA tab**, the lead's own **CTA tab**, and a **Triggered** view on the CTA Links page.

> **The page also hosts the Meta Messenger `m.me` links (2026-07-27).** "Where do I put a CTA?" is one question, so one page answers it: the list is grouped by platform, and the New CTA modal **leads with a Channel picker** spanning connected WhatsApp numbers, Messenger Pages and the plain-phone generator — picking a Page swaps the whole form to the m.me shape (name / ref token / description / ad attribution / status). Only the **read** side is merged: m.me links keep their own table, validation and endpoints (`manage.messenger.cta-links.*` → `Manage\Messenger\CtaLinksController`, which has no index of its own any more), and the modal posts to whichever backend owns the picked channel. The Messenger rows and the create option are gated on `view-messenger-settings` / `manage-messenger`, so a WhatsApp-only admin sees the page exactly as before. The **Triggered** view stays WhatsApp-only — Messenger attributions are recorded against the ref token and surface on the lead. See [messenger/readMe.md](/docs/modules_handbook/manage/messages/messenger/readMe.md).

> **Same concept as a flow's keyword trigger, one level up.** A keyword flow answers "what do we do when this text arrives"; a CTA link answers "where did this text come from" — it owns the shareable URL + the lead attribution, and *delegates* the conversation handling to an attached flow (or, with none attached, leaves the normal keyword-flow / default-AI engine untouched).

## How it works
- **Data model (2 tables).** `WhatsappCtaLink` (key model: uuid + blame + soft delete) = **`channel_id` (nullable — null = plain-phone mode)**, **`phone_e164`** (the resolved target number: the channel's, or the entered one), **`slug`** (unique — the short link), **`click_count`** + **`last_clicked_at`**, `name`, `description`, **`default_text`** (the wa.me prefill), **`match_text`** (optional short token — see matching below), `match_mode` (`MATCH_EXACT` / `MATCH_CONTAINS`), `status` (INACTIVE/ACTIVE), **`flow_id`** (optional flow to start, same channel), **`project_id`** (optional — a captured lead is auto-added to this project's sales pipeline; NOT channel-scoped, so it works on plain-phone links too — though those never capture, so it only bites on channel links). `WhatsappCtaCapture` (child, no uuid) = one CTA touch: `cta_link_id` + `contact_id` (**`UNIQUE` — first-touch per link per person**; a repeat send never double-counts), `conversation_id`, `message_id` (the matched inbound), **`lead_id`** (the CRM lead; null when the phone belongs to a staff member), **`event_id`** (the session whose webinar was live at capture time, else null), `captured_at`. `isCapturing()` = `channel_id !== null`.
- **The shareable URL + short link.** `getWaUrlAttribute()` builds `https://wa.me/{digits}?text={rawurlencode(default_text)}` from `phone_e164` (falls back to the channel's number for legacy rows). `getShortUrlAttribute()` = `url('/wa/'.$slug)`. The public **`GET /wa/{slug}`** (`Main\CtaRedirectController`, route `cta.short`, declared **above** the `/{slug}` + `/{funnel}/{slot}` funnel catch-alls; `wa` is a reserved funnel slug) resolves an ACTIVE link, tallies a click via `WhatsappCtaLinkRepository::recordClick()` (atomic increment + `last_clicked_at`), and 302-redirects to the wa.me URL — unknown/inactive/phoneless links fall back to the site home so a shared link never dead-ends. The index shows the short link + click count with a copy button; the form modal shows a live phone preview while typing.
- **Matching.** `matchesText()` is case-insensitive and compares the trimmed inbound against **`match_text` when set, else the whole `default_text`** — `exact` = the whole message, `contains` = substring. Best practice (hinted in the form): put a short unique token in the text (e.g. `(ZOOM26)`), set it as the match text with *contains* — the capture then survives customers editing the pre-filled message. **A token requires *contains*** — under *exact* the whole message would be compared against the token alone, so the canonical click-through could never match; `StoreRequest` rejects the pairing (`prohibited_unless`) and the form modal auto-switches the mode the moment a token is typed.
- **Inbound hook.** `ProcessInboundWhatsAppWebhook::considerCtaCapture()` runs on every 1:1 inbound TEXT **before `considerFlow`** (lead capture + attribution must happen even when a keyword flow also matches). Guards mirror the flow engine: never on groups, history backfills, or pinned sandbox pairing-test threads (`test_config` — those are reserved for their flow×profile pin; the **default** sandbox thread still captures, so the whole path is testable with "send as customer"). On the first ACTIVE matching link (by id) it:
  1. **Captures the CRM lead** — `ContactLinker::link($contact, $group_id)` (the **same tolerant linker the general inbound hook already ran**, so a CTA hit reuses that single match instead of fabricating a divergent duplicate; `$lead = Lead::where('user_id', $result['user_id'])`, null for a staff phone; a lead hiccup never loses the touch). *(Was `firstOrCreateForPhone` originally — swapped to `ContactLinker` to avoid a non-tolerant duplicate lead.)*
  2. **Records the touch** — `WhatsappCtaLinkRepository::recordCapture()`: first-or-create the `(link, contact)` row, **resolve `event_id`** (the session whose Zoom webinar window contains the message time, preferring one Zoom flagged LIVE; `resolveLiveEventId()`), backfill `lead_id` on an earlier lead-less row, and **link `whatsapp_contacts.user_id` to the lead's account** when not already linked (never clobbers). A **plain-phone link never reaches here** — `matchingCtaLink()` only queries links on the arriving channel, so channel-less links can't capture;
  3. **Optionally opens the project's pipeline** — when the link has a **`project_id`** and a lead was captured, `EngagementRepository::open(lead + project)` adds the lead to that project's sales pipeline at **NEW, staffed with the standing team** — each active `PipelineRole`'s `default_admin_id` (Setting → Closing Modes) — the same as the project page's manual *Add Lead*. **Best-effort + idempotent**, isolated in its own try/catch: opening is unique per `(lead, project)` and never resets a running stage, so re-messaging never opens a second pipeline, and a pipeline hiccup never breaks the capture or the flow start. Booking is NOT created — capture only knows the lead + project, not a unit/price. See [Engagements](/docs/modules_handbook/manage/engagement/readMe.md);
  4. **Optionally starts the link's flow** — only when the flow is ACTIVE, on the same channel, and **that flow TYPE's run slot is free** (a running run is never hijacked). Per-TYPE since the 2026-07-14 parallel slots, not per-conversation: a CTA may start a RULE_BASED bot while a TIME_BASED drip ticks in the background, but never a second run of the same type. The check-then-create is serialised under a per-conversation **`Cache::lock('wa:flow-start:{id}')`** (shared with the keyword trigger) so two concurrent inbound messages can never start two RUNNING runs. Started via the normal `WhatsappFlowRepository::startRun` (+ `AdvanceFlowRun` dispatch), `meta.started_by = 'cta_link'`. When a flow starts, `considerCtaCapture` returns true and the caller skips the keyword trigger **and** the default AI — the flow's drip is the reply. With no flow attached it returns false, so keyword flows / the channel's default AI handle the message exactly as before.
- **Admin UI (modal CRUD, mirrors Segments).** `CtaLinks/Index.vue` has a **Links | Triggered** toggle. **Links** = card list (name, status pill, **Capturing / Link-only** badge, channel or phone chip, violet flow chip, the **short link + click count + captures/leads counts**) with row actions Copy short link / Activate-Deactivate / Edit / Delete (ConfirmModal). **Triggered** = every recent capture (linked lead, CTA name, phone, time, and a green **Live: {session}** chip when it landed during a webinar). `Partials/CtaLinkFormModal.vue` = a **WaLink-style two-column** create/edit modal (form + a live WhatsApp phone preview): a **source toggle** (Connected channel — captures / Phone number — link only), the channel select **or** a country-code + number entry, name, custom message (with an emoji quick-bar), an **optional project `ComboBox`** (searchable by name, via `manage.sales-projects.search` — shown for both modes since a project isn't channel-scoped), and — **channel mode only** — match mode + optional token + the per-channel flow select. The **source + channel are fixed after creation**. No separate pages — the thin (non-DataTable) §14 variant, like Tags/Segments.
- **Lead attribution display.** `LeadsController@show` passes a **`whatsappCtas`** prop (captures for the lead, with the CTA name, channel, matched message body, time, and the **live session** it landed during); the Lead Show page has a dedicated **CTA tab** (`Partials/Tabs/CtaTab.vue`) — moved out of the Attribution tab, and since 2026-07-17 nested under the Lead's **Channel** tab at `?tab=channel&ctab=cta` — that lists each touch with an emerald **"During live session: {title}"** link to the session. See [Leads](/docs/modules_handbook/manage/leads/readMe.md).
- **Session CTA tab.** `EventsController@show` passes a **`ctaCaptures`** prop (captures where `event_id` = this session). The session detail page (`Events/Show.vue`) gains a **CTA tab** (`Partials/Tabs/CtaTab.vue`, shown on Zoom sessions or whenever captures exist) listing who messaged a CTA in while the webinar was live — linked lead, CTA name, phone, message, time. See [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md).
- All writes go through `WhatsappCtaLinkRepository` inside `DB::transaction`; the controller stays thin (explicit `mapInput`, uuid lookups, `flash()` + `back()`).

## GUIDELINES alignment
- **§2/§3** — writes via `WhatsappCtaLinkRepository` in `DB::transaction` (create/update/setStatus/delete/recordCapture); thin controller with explicit input mapping; validation in Form Requests (`CtaLinks/StoreRequest` + `UpdateRequest` — channel dropped on update).
- **§3 constants** — `STATUS_*`/`STATUSES`, `MATCH_*`/`MATCHES`; no magic numbers (the one JS mirror, `STATUS_ACTIVE = 2` in `Index.vue`, is commented as such).
- **§7** — key model + child capture table (no uuid), snake_case ≤30-char columns, indexed FKs, `UNIQUE(cta_link_id, contact_id)`, no schema FKs; a new migration only.
- **§13/§14** — Inertia + Vue 3 + Tailwind; modal CRUD on the index (Segments pattern); `ConfirmModal` for delete.

## Testing (no real WhatsApp)
`tests/Feature/Whatsapp/CtaLinkTest.php` (13 tests) drives the whole engine through the real pipeline on a Sandbox channel: matching semantics + the wa.me URL encoding, lead + profile creation, contact→account linking, first-touch dedupe, inactive/non-matching no-ops, the CTA-flow start (with the default AI suppressed), the no-flow path, the never-hijack-a-running-flow rule, **the short-link redirect + click tally + unknown/inactive fallback, the live-webinar session tagging (`event_id` set during a LIVE window, null outside), and that a plain-phone link never captures**. In the UI: create a CTA link on the Sandbox channel, open `/manage/messages/sandbox`, and send the default text via the violet "send as customer" bar.

## Related files

**Backend**
- [src/Whatsapp/WhatsappCtaLink.php](/src/Whatsapp/WhatsappCtaLink.php) — the link (key model); `STATUS_*` / `MATCH_*`; `matchesText()` / `matchText()` / `wa_url`; `channel()` / `flow()` / `captures()`.
- [src/Whatsapp/WhatsappCtaCapture.php](/src/Whatsapp/WhatsappCtaCapture.php) — one CTA touch (child, no uuid); `ctaLink()` / `contact()` / `conversation()` / `message()` / `lead()`.
- [src/Whatsapp/Repositories/WhatsappCtaLinkRepository.php](/src/Whatsapp/Repositories/WhatsappCtaLinkRepository.php) — create (auto-slug) / update (channel immutable) / setStatus / delete + `recordCapture()` (first-touch + `event_id` resolution + lead backfill + contact→account link) + `recordClick()` + `resolveLiveEventId()`.

> ⚠️ **`resolveLiveEventId()` — the two rules that decide whether a tap attributes at all** (both fixed 2026-08-10; before that **15 of 27 captures carried no `event_id`**):
> 1. **Only webinars BOUND to a session count** (`whereNotNull('event_id')`). A webinar that exists at Zoom but was never adopted keeps a null `event_id`, and that is the *common* case — **139 of 147 `zoom_webinars` rows** on the production snapshot. Without the filter such a row wins the `start_time DESC` ordering, `value('event_id')` reads its null, and a tap made during a real live session is silently dropped.
> 2. **The window closes on `ended_at`, not on `duration`** — plus `CAPTURE_GRACE_MINUTES` (45). The offer is pitched in the closing minutes and the person taps, switches app and types, so the inbound message routinely lands *after* Zoom reported the webinar ended; an over-running webinar is exactly the one whose taps matter, and the scheduled duration would have shut the window before they arrived. `ended_at` is null while a webinar is upcoming or live, so the scheduled end stays the fallback.
>
> Pinned by `test_an_unbound_webinar_never_swallows_a_bound_sessions_capture` and `test_a_tap_shortly_after_an_overrunning_webinar_still_attributes` — both verified to FAIL on the pre-fix query.
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — `considerCtaCapture()` / `matchingCtaLink()` (runs before `considerFlow`; channel-scoped so plain-phone links never match). Captures the lead via `ContactLinker`, then — when the link has a `project_id` — opens the project pipeline via `EngagementRepository::open()` (best-effort, before the flow start).
- [app/Http/Controllers/Manage/Engagements/SalesProjectsController.php](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php) `search()` — the project typeahead for the form ComboBox (route `manage.sales-projects.search`). Distinct from the feature-flagged, FocusProject-only `manage.projects.search`.
- [app/Http/Controllers/Manage/Whatsapp/CtaLinksController.php](/app/Http/Controllers/Manage/Whatsapp/CtaLinksController.php) — index (+ `recentCaptures()` for the Triggered view) / store / update (dual-mode channel|phone) / activate / deactivate / destroy.
- [app/Http/Controllers/Main/CtaRedirectController.php](/app/Http/Controllers/Main/CtaRedirectController.php) — the public `/wa/{slug}` short-link redirect (+ click tally).
- [app/Http/Requests/Manage/Whatsapp/CtaLinks/StoreRequest.php](/app/Http/Requests/Manage/Whatsapp/CtaLinks/StoreRequest.php) (source + channel|phone) · [UpdateRequest.php](/app/Http/Requests/Manage/Whatsapp/CtaLinks/UpdateRequest.php).
- [app/Http/Controllers/Manage/Events/EventsController.php](/app/Http/Controllers/Manage/Events/EventsController.php) — the `ctaCaptures` session Show prop (`buildCtaCaptures()`).
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — the `whatsappCtas` Show prop (with the live session).
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) — `firstOrCreateForPhone()` (shared with the proactive flow starter).

**Frontend (Vue)**
- [resources/js/Pages/Manage/Messages/CtaLinks/Index.vue](/resources/js/Pages/Manage/Messages/CtaLinks/Index.vue) — the management page (Links | Triggered toggle, short link + click count, modal CRUD) — lists the WhatsApp links **and** the Meta Messenger m.me links, grouped by platform.
- [resources/js/Pages/Manage/Messages/CtaLinks/Partials/CtaLinkFormModal.vue](/resources/js/Pages/Manage/Messages/CtaLinks/Partials/CtaLinkFormModal.vue) — create/edit modal: a cross-platform **Channel** picker first, then either the WaLink-style WhatsApp form (dual-mode source, live phone preview, emoji bar, per-channel flow select) or the Messenger m.me form. Each platform gets its **own `useForm`** against its own endpoint, so validation errors always map back to the fields actually on screen.
- [resources/js/Pages/Manage/Leads/Partials/Tabs/CtaTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/CtaTab.vue) — the lead's dedicated CTA tab (with live-session link) · [AttributionTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/AttributionTab.vue) — CTA card removed · [Pages/Manage/Leads/Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) — the CTA tab.
- [resources/js/Pages/Manage/Events/Partials/Tabs/CtaTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/CtaTab.vue) — the session's CTA tab · [Pages/Manage/Events/Show.vue](/resources/js/Pages/Manage/Events/Show.vue) — wires it.
- [resources/js/Components/Messages/MessagesTabs.vue](/resources/js/Components/Messages/MessagesTabs.vue) — the hub strip this page sits in (AI Automation → CTA Links / AI Setting / Flows / Sandbox); the sidebar holds a single **Messages** entry ([ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue), 3 suites).

**Migration + tests**
- [database/migrations/2026_07_02_140001_create_whatsapp_cta_tables.php](/database/migrations/2026_07_02_140001_create_whatsapp_cta_tables.php) — `whatsapp_cta_links` + `whatsapp_cta_captures`.
- [database/migrations/2026_07_14_000020_add_shortlink_and_session_to_whatsapp_cta.php](/database/migrations/2026_07_14_000020_add_shortlink_and_session_to_whatsapp_cta.php) — nullable `channel_id` + `phone_e164` + `slug` + `click_count` + `last_clicked_at`; capture `event_id`.
- [database/migrations/2026_07_18_100002_add_project_id_to_whatsapp_cta_links.php](/database/migrations/2026_07_18_100002_add_project_id_to_whatsapp_cta_links.php) — nullable `project_id` (the auto-open-pipeline target).
- [tests/Feature/Whatsapp/CtaLinkTest.php](/tests/Feature/Whatsapp/CtaLinkTest.php) — the engine suite (13 tests, real pipeline on a Sandbox channel).

**Routes**
- [routes/web.php](/routes/web.php) — `manage.messages.cta-links.*` (index / store / activate / deactivate / update / destroy).
- [routes/main.php](/routes/main.php) — public `cta.short` (`GET /wa/{slug}`), declared above the funnel catch-alls; `wa` reserved in `Funnels\StoreRequest::RESERVED_SLUGS`.

**Related handbooks**
- [Flow](/docs/modules_handbook/manage/messages/whatsapp/flow.md) (the attached flow's drip / AI takeover) · [Leads](/docs/modules_handbook/manage/leads/readMe.md) (where captures surface) · [readMe](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) (the inbox + inbound pipeline).
