# Meta Pixel & Conversions API (shared · `Src\Marketing`)

**Context:** Main portal (public pages) + queue · **Config:** `services.meta.pixel_id` / `capi_token` / `capi_test_code` · **Env:** `META_PIXEL_ID`, `META_CAPI_TOKEN`, `META_CAPI_TEST_CODE`

Conversion tracking for the funnel landing pages: it tells **Meta** which visitors actually registered, so the ad algorithm can optimise for *registrations* instead of *clicks*, and so retargeting / Lookalike audiences can be built.

> **Not the same thing as [session CPL](/docs/modules_handbook/manage/events/funnels/readMe.md).** The Pixel *feeds Meta* (optimisation); our own `lead_funnels` + `event_registrations` attribution is what *we* report on. The two numbers legitimately differ — Meta counts under its own 7-day-click / 1-day-view attribution window, ours counts real CRM registrations for a specific session.

## What it does
- **Browser (Pixel)** — a base snippet renders in `<head>` and fires `PageView`; the SPA fires it again on every Inertia navigation. Opening the capture modal fires **`ViewContent`**; pressing submit fires **`SubmitApplication`**; a successful registration fires **`Lead`** *and* **`CompleteRegistration`**.
- **Server (money)** — a fulfilled payment reports **`Purchase`** with its amount and currency, so Meta can optimise for people who *spend* rather than people who fill in forms. Server-only, first-touch attributed, and gated hard (see below).
- **Two events for one submit, on purpose.** A completed capture form genuinely is both in Meta's vocabulary — contact details handed over (`Lead`) *and* a sign-up finished (`CompleteRegistration`) — so an advertiser can optimise a campaign on either without the other polluting it. They are **not** duplicates of each other: different event names, different ids.
- **Server (Conversions API)** — both conversions are ALSO reported from the backend, carrying the person's **hashed** email / phone (Advanced Matching). This half survives ad blockers, iOS ATT and ITP, which silently drop a large share of browser events.
- **Deduplication** — Meta dedupes on the **(`event_name`, `event_id`) pair**, so each event needs its own id: `Lead` uses the id minted client-side per submit and posted with the form; `CompleteRegistration` uses that id **+ `-cr`**, derived **server-side** in `LandingController::completeRegistrationEventId()` and handed to the browser in the thank-you payload, so the two languages can never disagree on how to build it. ⚠️ Giving both events the SAME id is the one mistake to avoid — it is what makes Meta collapse them into a single conversion. No posted id (Pixel blocked, JS off) ⇒ **both** halves go out without a dedup key, rather than only one carrying it.

## How it works
- **One dataset for everything.** A Pixel/dataset is a **Business-level** asset, shared to *both* ad accounts (FMX + MKT) — not one pixel per ad account. A single pool keeps Meta's learning from being split in half. Current dataset: **`PetaV3 Production`**.
- **Where it renders.** `resources/views/partials/meta-pixel.blade.php`, included from `app.blade.php`. Two hard gates: a pixel id must be configured, and the request must **not** be `manage` / `manage/*` — the admin backend is staff traffic and would poison the dataset.
- **SPA PageView.** Inertia never reloads the document, so `resources/js/app.js` fires `PageView` on each `navigate` — but **only when the URL actually changed**, compared against the previous one. The first URL is **primed at module scope** from `window.location` so the initial page (already tracked by the Blade snippet) is never double-counted; `navigate` can fire before `setup()`, so priming inside `setup()` would be too late.
  ⚠️ **Both sides of that comparison are percent-DECODED first** (`normalizePixelUrl()`, with a fallback for malformed escapes). This is not tidiness: the browser reports its query **encoded** (`?ad_id=%7B%7Bad.id%7D%7D`) while Inertia reports it **raw** (`?ad_id={{ad.id}}`), so the two strings never matched for a URL carrying Meta's `{{…}}` dynamic parameters — i.e. **exactly the ad traffic this Pixel exists for** — and every such landing page fired `PageView` twice.
- **Safe wrapper.** All client calls go through `resources/js/utils/metaPixel.js` (`trackPixel`, `newEventId`, `pixelReady`), which no-ops when `fbq` is absent (admin page, unconfigured env, ad blocker). A tracking call must never break a page.
- **The server half.** `LandingController::reportConversionToMeta()` assembles the payload — it is the only place that holds the visitor's `_fbp` / `_fbc` cookies, IP and user agent — and queues **two** `SendMetaCapiEventJob`s (`afterCommit`, `tries = 1`), one per event. They share an `$identity` array; only the name, the id and the custom data differ. `Src\Marketing\Services\MetaConversionsApi` POSTs to `/{pixel_id}/events` and **fails soft** (`['ok','error','data']`, never throws).
- **The CAPI Graph version is pinned in CODE, not env.** `MetaConversionsApi::GRAPH_VERSION = 'v21.0'` builds the endpoint; it deliberately ignores `META_GRAPH_VERSION` (`services.meta.graph_version`, default `v19.0`) and `FACEBOOK_GRAPH_VERSION` (`services.facebook.graph_version`). Bumping either env var does **not** move this endpoint — upgrading it is a code change. The POST also reuses the ads client's `FB_AD_CA_BUNDLE` (falling back to `storage/certs/cacert.pem`) for the Laragon/Windows cURL-77 TLS workaround.
- **`_fbc` reconstruction.** The click-id cookie only exists once the Pixel has seen an `fbclid`. When it is missing but the form posted an `fbclid`, the payload rebuilds it in Meta's documented `fb.1.<ms>.<fbclid>` shape.
- **Hashing (Meta's required normalisation).** email → trim + lowercase → SHA-256; phone → digits only **including country code** → SHA-256; name → first token, lowercased → SHA-256. Raw PII is never sent — a test asserts this.
- **Token chain.** `META_CAPI_TOKEN` → `FB_AD_ACCESS_TOKEN` → `META_ACCESS_TOKEN` → the first **ACTIVE system-user** `FacebookIntegration`. Each is tried until one succeeds, so most deployments need no new env at all.

## Events sent

| Event | When | Where |
|---|---|---|
| `PageView` | Every page (initial + each Inertia navigation) | Blade snippet + `app.js` |
| `ViewContent` | Visitor opens the lead-capture modal (intent signal) | `LeadCaptureModal.vue` (browser only — no server half, so no `event_id`) |
| `SubmitApplication` | The visitor pressed submit **and the form passed client-side validation** — fired before the POST, so it counts valid attempts, not successes (`submit()` returns early on `!canSubmit` and fires nothing) | `LeadCaptureForm.vue` `submit()` (browser only, deliberately **no** `event_id`) |
| `Lead` | Capture form submitted — **a conversion Meta can optimise for** | `ThankYou.vue` `onMounted` (browser) + `SendMetaCapiEventJob` (server), both with `event_id` |
| `CompleteRegistration` | The SAME submit — **the other conversion Meta can optimise for** | `ThankYou.vue` `onMounted` (browser) + `SendMetaCapiEventJob` (server), both with `event_id` **+ `-cr`**; carries `status` = `new` \| `returning` |
| `Purchase` | A payment was actually **fulfilled** (any lane) | `ReportPurchaseToMetaAction` → `SendMetaCapiEventJob` (server only, `action_source: system_generated`, stable `event_id`) |

Both *registration* conversions fire from the **thank-you page**, not at submit — a redirect would race a fire-then-navigate, whereas that page is guaranteed to have loaded. `LeadCaptureForm.vue` **mints** the shared `Lead` id and fires exactly one event of its own, `SubmitApplication`.

**Why `SubmitApplication` is browser-only and id-less.** `Lead` / `CompleteRegistration` only exist once `/thank-you` has loaded, so everyone lost in between — a server error, a dropped connection, a closed tab — is invisible in Events Manager. `SubmitApplication` is the "somebody pressed the button" marker, and the gap between it and `Lead` **is** the drop-off rate of the submit itself. It gets no `event_id` on purpose: the server only ever hears about submissions that *succeed*, which is precisely the population this event exists to look past, so there is no server half to deduplicate against. Firing it before `form.post()` is safe because Inertia posts by XHR — the document is not unloading.

The thank-you payload is a session value (not a flash), so a refresh re-mounts the page and re-fires both events — with the *same* ids, which is exactly what stops a refresh from double-counting.

> ⚠️ **The two halves report DIFFERENT source URLs, and this is the one thing to know before building a URL-based Custom Conversion.** `fbq` always stamps the event with the page it fires on, which is now `/thank-you` — one shared page for every funnel. The server half instead sends `event_source_url` = the **landing** URL (`landing_url`, falling back to the `Referer`). Dedup is unaffected (that keys on event_name + event_id only), but in Events Manager the **Top URLs** breakdown and any *"Lead where URL contains …"* rule see `/thank-you` for the browser half, so per-funnel slicing by URL collapses into a single bucket.
>
> **Slice by `content_name` instead** — it carries the funnel's name on *both* halves of *both* events, and it is the discriminator this design relies on. This is a consequence of moving the confirmation to a shared page (2026-07-27); before that the browser `Lead` fired on the landing itself and both halves agreed.

## `Purchase` — the money event (SHIPPED 2026-07-29)

> Planned 2026-07-28, built 2026-07-29. **This is the canonical description** — the payments handbook deliberately defers here ([payment-links/readMe.md](/docs/modules_handbook/manage/payments/payment-links/readMe.md): *"What it sends and why is documented with the Meta/marketing integration, not here"*).

**Why.** Every other event we send tops out at *"this person registered"*, so Meta can only optimise for **more registrations**. `Purchase` **with an amount** lets it optimise for *people who actually spend money* — a different, much smaller audience than "people who fill in forms" — and unlocks ROAS reporting and value-based Lookalikes.

**Where it hooks in.** [`App\Actions\Marketing\ReportPurchaseToMetaAction`](/app/Actions/Marketing/ReportPurchaseToMetaAction.php), called from [`PurchaseFulfiller::fulfill()`](/app/Services/Payment/PurchaseFulfiller.php) — the single place every payment lane (gateway webhook, portal checkout, manual offline entry, hosted-link adoption backfill) converges on. **Every gate lives inside the action, not at the call site**, so it is safe to call from anywhere and impossible to bypass by adding a seventh lane later. The whole body is wrapped in `try/catch` + `report($e)` and writes only its own marker column: money-path *adjacent*, never money-*critical* — a Meta outage must not cost a customer their membership.

**Five gates, in order — each one exists because skipping it produced a wrong number:**

| Gate | Rule | Why |
|---|---|---|
| Status | `status === STATUS_ACTIVE` | UNCONFIRMED (checkout merely opened) and EXPIRED (abandoned) are not sales. **INACTIVE is a refund** — and a refund is reported by *not* having reported, since Meta has no "undo". |
| Idempotency | `meta_reported_at === null`, stamped after dispatch | `fulfill()` re-runs on **every edit of an already-ACTIVE row** (that is how a paid-but-unattributed payment gets its buyer attached); without the marker one sale is re-reported on each edit, inflating both the conversion count and the revenue Meta optimises against. |
| Age | `purchased_at` present **and** within **`MAX_EVENT_AGE_DAYS = 7`** (a null `purchased_at` is skipped too — we will not guess when the money arrived) | ⚠️ Meta **rejects** an `event_time` older than 7 days, silently, into Diagnostics only. The hosted-link adoption backfill records months-old payments as fresh ACTIVE rows. Backdating to `now()` would be worse — it would fabricate a conversion date and credit historical revenue to whatever is running today. Historical money is simply left **unstamped**, so nothing pretends otherwise. |
| Buyer | `$purchase->lead !== null` | A webhook may record money ACTIVE but unattributed on purpose. Left unstamped: attaching the buyer later re-runs `fulfill()` and it goes out then. |
| Ad touch | `firstAdTouch()` found a row | No ad brought this person in (walk-in, referral, offline import) ⇒ nothing for Meta to learn from and no ad to credit. Sending it anyway teaches the algorithm from people it never reached. **This silently skips every non-ad lead — expect fewer `Purchase` events than sales.** |

**First-touch attribution (product decision 2026-07-29).** `firstAdTouch()` takes the **earliest** `lead_funnels` row for the lead carrying *any* Meta click marker — `fbclid` **or** `fbc` **or** `campaign_id` **or** `ad_id` — ordered by `registered_at` then `id` (the id breaks a same-instant tie deterministically, so the credited ad never flip-flops). Deliberately broad: the `campaign_id`/`ad_id` URL macros only exist when the ad's own URL carries them, but an `fbclid` alone still proves the visit came from Meta. First touch, not last, because it is the ad that *brought them in* — and that is the number that answers "where do I put budget to find NEW buyers".

**Payload.** Server-only (no browser at payment time, so there is no half to deduplicate against), but the `event_id` is still **stable and derived**, so a job retry or an import replay deduplicates at Meta's end as well as ours:

```php
SendMetaCapiEventJob::dispatch([
    'event_name'       => 'Purchase',
    'event_id'         => 'purchase-' . $purchase->uuid,   // stable ⇒ replay-safe
    'action_source'    => 'system_generated',              // NOT 'website' — nobody was on a page
    'event_time'       => optional($purchase->purchased_at)->timestamp ?: time(),
    'event_source_url' => $touch->landing_url,             // the ad's landing page, not the checkout
    'email'            => $user?->email,                   // hashed by the service
    'phone'            => $user?->profile?->phone,
    'name'             => $user?->profile?->full_name,
    'fbp'              => $touch->fbp,                     // replayed from the REGISTRATION row
    'fbc'              => $touch->fbc,
    'custom'           => [
        'value'        => (float) $purchase->total_amount,
        'currency'     => $purchase->currency_code,        // REQUIRED by Meta for Purchase
        'content_name' => $purchase->title,
        'content_type' => 'product',
    ],
]);
```

⚠️ `currency` is **mandatory** for `Purchase` — Meta rejects the event without it, and the rejection is only visible in Events Manager → Diagnostics, never to anyone in the flow. The action hardcodes nothing — it reads the row's `currency_code` accessor, so the gateway's currency wins (see [Purchase Histories](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md)). ⚠️ That accessor itself falls back to `CURRENCY_DEFAULT = 'MYR'` when the `currency` column is empty, which is how rows written before the currency migration still report something.

**Which amount.** `purchase_histories.total_amount` — what was actually charged, on the ledger row that recorded it. This must stay the SAME field forever: changing it later silently rewrites the meaning of every historical ROAS number in Ads Manager. *(The original plan sourced the amount from `Src\Engagement\Booking`'s five price columns; keying on the purchase ledger instead removed that choice entirely, because every lane already normalises to one charged total.)*

**Three prerequisites, all now done** — kept because each explains a line of code that looks arbitrary otherwise:

1. **`action_source` is a per-event input**, defaulting to `website` (`MetaConversionsApi::send()`). A payment confirmed by a webhook, an admin or a cron is not a website action; claiming `website` for one is a data-quality problem Meta flags.
2. **`custom_data` uses an explicit callback**, `fn ($value) => $value !== null && $value !== '' && $value !== []` — *not* a bare `array_filter`, which also strips `false` and **`0`**, so a legitimate `'value' => 0` (a free or fully-discounted purchase) would vanish and Meta would get a `Purchase` with no amount. Pinned by `PurchaseCapiTest::test_a_free_purchase_still_carries_its_zero_value`.
3. **`_fbp` / `_fbc` are persisted on `lead_funnels`** (migration `2026_07_29_200001`): captured by `LandingController` (`_fbp` cookie; `_fbc` from the cookie or rebuilt via `clickId()`), written by `RegisterLeadAction`, replayed here. They live on the **registration** row rather than the lead — like the `fbclid`/`campaign_id` columns beside them — because they belong to the *ad touch*: registering again through a different ad yields a different pair. Without them a purchase days later carries only the hashed email/phone and Meta's match quality (hence optimisation and reported ROAS) drops.

**Still open:** `CompleteRegistration` fires when the **form is submitted**. If "registered" should instead mean "verified email + phone and the account went live", it would move server-side to `Auth\RegisterController@verify` — which takes no `event_id` today, so one would have to be threaded through (`ContactVerification.vue` already supports an `extra` prop that merges into both posts).

## Setup / operations
1. **Dataset**: Events Manager → the dataset's id (shown under its name). Share it to every ad account in Business Settings → Data Sources → Datasets → *Assign assets*. ⚠️ Meta only allows **one dataset creation per ad account**, so create at the **Business** level (or rename/reuse an existing empty dataset).
2. **Env**: `META_PIXEL_ID=…`. Optionally `META_CAPI_TOKEN=…` (Events Manager → Settings → Generate access token) — otherwise the existing ads/system-user tokens are used.
3. **Verify**: set `META_CAPI_TEST_CODE=…` (Events Manager → **Test Events**) and register once — the browser and server events should appear and show as **deduplicated**. ⚠️ Remove it afterwards: test events are excluded from optimisation.
4. **Switch the ads**: with both events flowing, campaigns can move from a traffic objective to **Leads / website conversion**. Optimise on **`Lead`** or **`CompleteRegistration`** — pick ONE per campaign; they describe the same submit, so optimising on both splits Meta's learning.
5. **Privacy**: the site's privacy policy must disclose Meta tracking (PDPA / Meta's terms). `services.meta.privacy_policy_url` already exists.

## Related files
**Config** — [`config/services.php`](/config/services.php) (`meta.pixel_id`, `meta.capi_token`, `meta.capi_test_code`)
**Browser** — [`resources/views/partials/meta-pixel.blade.php`](/resources/views/partials/meta-pixel.blade.php) · [`resources/views/app.blade.php`](/resources/views/app.blade.php) · [`resources/js/utils/metaPixel.js`](/resources/js/utils/metaPixel.js) · [`resources/js/app.js`](/resources/js/app.js) (SPA PageView) · [`resources/js/Components/LeadCaptureModal.vue`](/resources/js/Components/LeadCaptureModal.vue) (`ViewContent`) · [`resources/js/Components/LeadCaptureForm.vue`](/resources/js/Components/LeadCaptureForm.vue) (**mints** the `Lead` `event_id`; fires `SubmitApplication`) · [`resources/js/Pages/ThankYou.vue`](/resources/js/Pages/ThankYou.vue) (`Lead` + `CompleteRegistration`, in `onMounted`)
**Server** — [`src/Marketing/Services/MetaConversionsApi.php`](/src/Marketing/Services/MetaConversionsApi.php) · [`app/Jobs/Marketing/SendMetaCapiEventJob.php`](/app/Jobs/Marketing/SendMetaCapiEventJob.php) · [`app/Http/Controllers/Main/LandingController.php`](/app/Http/Controllers/Main/LandingController.php) (`reportConversionToMeta`, `completeRegistrationEventId`, `clickId`) · [`app/Http/Controllers/Main/ThankYouController.php`](/app/Http/Controllers/Main/ThankYouController.php) (`eventId` / `registrationEventId` props) · [`app/Http/Requests/Main/RegisterRequest.php`](/app/Http/Requests/Main/RegisterRequest.php) (`event_id`)
**Purchase** — [`app/Actions/Marketing/ReportPurchaseToMetaAction.php`](/app/Actions/Marketing/ReportPurchaseToMetaAction.php) (all five gates + `firstAdTouch()`) · called from [`app/Services/Payment/PurchaseFulfiller.php`](/app/Services/Payment/PurchaseFulfiller.php) (`fulfill()`, before any lane branches) · [`app/Actions/RegisterLeadAction.php`](/app/Actions/RegisterLeadAction.php) (writes `fbp` / `fbc`) · [`src/Lead/LeadFunnel.php`](/src/Lead/LeadFunnel.php) (`fbp`, `fbc` in `$fillable`)
**Tests** — [`tests/Feature/Marketing/MetaPixelCapiTest.php`](/tests/Feature/Marketing/MetaPixelCapiTest.php) (registration halves, hashing, dedup ids) · [`tests/Feature/Marketing/PurchaseCapiTest.php`](/tests/Feature/Marketing/PurchaseCapiTest.php) (every `Purchase` gate: first-touch credit, send-once, the 7-day wall, no-buyer-yet, zero-value)
**Migrations** — [`2026_07_29_200001_add_meta_browser_ids_to_lead_funnels.php`](/database/migrations/2026_07_29_200001_add_meta_browser_ids_to_lead_funnels.php) (`fbp` / `fbc` on the registration row) · [`2026_07_29_200002_add_meta_reported_at_to_purchase_histories.php`](/database/migrations/2026_07_29_200002_add_meta_reported_at_to_purchase_histories.php) (the send-once marker, indexed, never back-filled — a null on an old row truthfully means "we never told Meta"). Nothing else is stored locally; the events themselves go straight to Meta.

**See also:** [Marketing / Ad Insights](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md) (spend, campaign mapping, session CPL) · [Facebook Connection](/docs/modules_handbook/manage/meta-ads/connection/readMe.md) (the tokens this borrows) · [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) (the registration this tracks)
