# Dog Academy: Meta Pixel + Conversions API build spec (website route)

Version 1.0, 2026-09-28. **Status: specification only.** Nothing has been built, sent or changed in Meta.

- **Audience:** DA's Laravel developer builds it. AdMaker checks it.
- **Placeholders:** every token and secret is written `<LIKE_THIS>`.
- **Pixel ID:** `481578137302100` is public, so it appears as-is.
- **Companion files, all in this folder:**
  - [`event-contract.json`](event-contract.json) is the canonical contract. Where the two disagree, the JSON wins.
  - [`QA-RUNBOOK.md`](QA-RUNBOOK.md) has the tests and the go/no-go gate.
  - [`sources.md`](sources.md) resolves every `[D#]` (Meta for Developers), `[H#]` (Meta Business Help Center) and `[I#]` (our own research) tag. All Meta pages were accessed on 2026-09-28.
- **What changed from the 2026-09-25 research:** that research recommended Instant Forms [I3]. The operator has now locked the **website quiz** as the destination, so this spec covers the website route only.

---

## 0. Decisions and the three required answers

### 0.1 Recommended optimization event

**Optimize to `Schedule`.** This is the free consultation the visitor books on `/custom-training-program/schedule-consultation`.

- The pixel and the server both send it, with one shared `event_id` (`da:schedule:{lead_id}`).
- Campaign settings:
  - objective: **Leads**;
  - conversion location: **Website**;
  - dataset: `481578137302100`;
  - conversion event: **Schedule**;
  - attribution: **7-day click**. Whether to add 1-day view is the account plan's call.
- Meta lists `schedule` as a valid conversion event under Leads → Website [H3]. It is a real on-site action, it happens minutes after the click, and it filters out people who fill in the form but never book.
- **Never optimize to `Lead`.** That is the form fill, which the operator has ruled out [I6].

Upgrade paths, in order:

1. **A custom conversion, "Schedule – in service area".** This is the website `Schedule` filtered on the parameter `in_service_area = yes`. Switch to it once it has enough volume [H18].
2. **"Maximize number of qualified leads" as a challenger cell**, with CRM stages and the target set to *Consultation Completed*. This is only possible if Gate 0 (§0.4) shows it for Website.
3. **Genuine on-site downstream events** (option D in §0.2), if DA changes how it runs consultations or takes payment.

> **Correction to `PLAN-TODO.md` §3.3.** The plan says the website route "moves to `ConsultationCompleted` once the volume supports it" [I4]. Meta's documentation gives no path for a phone- or CRM-logged event to be the optimization event of a Website ad set (see §0.2). Optimizing on consultation-level quality needs route 2 or 3 above.

### 0.2 (a) Which `action_source` for each stage, and can a non-website event optimize a Website campaign?

**`action_source` per stage.** Meta requires the value to be accurate. "By using the Conversions API, you agree that the action_source parameter is accurate to the best of your knowledge" [D1].

| Stage | Where it really happens | `action_source` | Sender |
|---|---|---|---|
| Lead (quiz submitted) | dogacademy.org | `website` | Pixel + CAPI, shared `event_id` |
| Schedule, self-booked on the site | dogacademy.org | `website` | Pixel + CAPI, shared `event_id` |
| Schedule, booked by an agent on the phone | phone | `phone_call` | CAPI only (from the stage feed) |
| Consultation completed (virtual meet & greet logged in the CRM) | a phone or video call | `phone_call` for a phone call ("Conversion was made over the phone"); `other` for video or in person ("Conversion happened in a way that is not listed") [D1] | CAPI only |
| Purchase closed on the call (card taken on the phone or in a virtual terminal) | phone | `phone_call` | CAPI only |
| Purchase paid through DA's own checkout on dogacademy.org (pay-by-link) | dogacademy.org | `website` | Pixel on the confirmation page + CAPI, `event_id` = order ID |
| CRM stage events, **only in "crm mode"** (§6.6, and only if Gate 0 passes) | the CRM | `system_generated`, which Meta requires for every CRM lead-stage event [D14][D15] | CAPI only |

**Can an event with a non-website `action_source` be the optimization event of a Leads or Sales campaign whose conversion location is Website? Treat the answer as NO.** Nothing in Meta's documentation says it can.

Why the answer is no:
- **The conversion location is where the outcome happens.** It is "the place where your desired business outcome will occur" [H4]. The event list for Leads → Website is the standard web events: complete registration, contact, find location, lead, schedule, search, start trial, submit application, subscribe and view content [H3].
- **The action source decides the objective.** "The action source is required to determine the campaign objectives for which the event is intended", and offline events such as "phone calls" should use a value that is "not website" [D8].
- **Offline events are for measurement and audiences.** They "may be used for attributed offline events measurement, offline custom audience creation or measurement" [D11]. For offline data, "you can't use custom events for optimization or custom audiences", only for custom conversions and attribution [H12].
- **The only documented routes are specific ones.** Meta documents two ways to optimize on non-website events:
  - the **"Website and in-store"** location: Sales objective, `Purchase` only, `physical_store` events [H15][D12];
  - **CRM conversion leads**: `system_generated` events [D13–D16].
- **The general statement doesn't overrule the specifics.** The Server Event Parameters page does say "All action sources enable ad optimization capabilities" [D1]. That describes what the platform can do; it does not say a Website ad set will accept a `phone_call` event.
- **Custom conversions can be tied to other sources, but that proves nothing about optimization.** The custom-conversion API takes an `action_source_type` that includes `phone_call` [D20]. The only documented use of offline custom conversions is measurement [H12][H14].
- **Status: UNVERIFIED.** No Meta page says outright that a Website ad set ignores these events. Gate 0-B (§0.4) checks what the Ads Manager UI actually offers.

**Compliant set-ups and their trade-offs:**

| Option | What it is | Compliant? | Trade-offs |
|---|---|---|---|
| **A. Optimize to website `Schedule`** (recommended) | Leads → Website → Schedule, sent by pixel and CAPI | Yes | Still a proxy: a booking is not a show and not a sale. Volume is lower than Lead. With $10k total, expect "Learning limited", which Meta defines as the ad set being unlikely to get about 50 optimization events a week [H28]. |
| B. `Schedule` custom conversion (in service area) | A website `Schedule` filtered by a non-sensitive parameter [H18] | Yes, if the rule and name imply nothing sensitive [H25] | Cuts obvious non-buyers, but lowers volume again. It only becomes selectable once Meta has received it "enough times" [H18]. |
| C. Website + "Maximize number of qualified leads", fed by CRM stages | The quality goal. The Help Center lists it for Website [H1][H2] | Yes, if Meta offers it | The documentation conflicts (§0.4). It may need 200 leads a month, ≥60% coverage, a target stage at 1–40% within 28 days, and 1–2 months of training. Those numbers come from the Instant-Form documentation [I3][D16]. |
| D. Make the downstream steps real website events | (1) Take TFL payment through a secure pay-by-link on DA's own checkout → website `Purchase`. (2) Send meet & greet invitations through a dogacademy.org "join" page → website custom event `ConsultationJoined` | Yes, because the action really happens on the site | Changes how sales and consultations run. Purchase volume will stay far below what optimization needs [H28][I3]. The window is still 7 days (§0.3). |
| E. Website and in-store (omnichannel) | Sales → Website and in-store, `physical_store` purchases [H15] | **No for DA.** DA has no store, so labelling phone sales `physical_store` would be inaccurate [D1][D12] | Rejected. |
| F. Website and calls | Leads → Website and calls. It optimizes for website events plus calls placed from the ad's call button [H17][H2] | Yes | Meta measures those calls itself, so DA's outbound-call outcomes never reach it. It only offers the qualified-leads goal [H2]. Not now. |
| G. Send downstream stages as `website` events "tied to the original lead" (reusing the lead's URL, IP and user agent) | Relabelling | **No.** It breaks the accuracy agreement [D1][D8] | Rejected. Don't do it even if a practitioner recommends it. |
| H. Instant Forms + CAPI for CRM | The documented stage-optimization route [D13] | Yes | Rejected by the operator: the destination is the website form [I6]. |

### 0.3 (b) Attribution and optimization windows, and what they mean for a sale that closes days later

- **Website and in-store conversions use these standard windows:**
  - **click-through, 1 or 7 days** after a link click;
  - **view-through, 1 day** after an impression;
  - **engage-through, 1 day** after a non-link interaction or a 5-second video play.

  These settings "inform ad delivery and determine which conversions can be credited to your ads" [H7][H8]. The default is 7-day click or 1-day view [H10].
- **28-day click is reporting only.** It appears in *Compare attribution settings*, "regardless of the ones you chose to optimize for" [H9].
- **Which stages can count.** An event counts as an optimization event, and as an ad-set result, only if it happens **within 7 days of the ad click** (or 1 day of a view).
  - `Lead` and a self-booked `Schedule` happen minutes after the click, so they are fully inside the window.
  - `ConsultationCompleted` would only count if the consultation happened within 7 days of the click. It isn't eligible anyway (§0.2).
  - **A sale that closes 10–20 days after the click never counts** toward a Website ad set's optimization. That is why the optimization event is `Schedule`.
  - For the CRM route, Meta documents a limit of 28 days from lead to stage, but only for Instant Forms [D16]. Whether it applies to website leads is UNVERIFIED.
- **Upload deadline.** Every event must reach Meta within 7 days of its `event_time`, or "we return an error for the entire request" [D1][D7]. Aim for **under 1 hour**:
  - "Sending your events more than 2 hours after they occurred can cause a significant decrease in performance";
  - "a delay of 24 hours or more may experience significant issues with attribution and optimized ad delivery" [D10].
- **Offline events are matched against impressions** "for events that occurred within 1, 7 or 28 days of an impression" [H13]. That is measurement only.
- **Measure the true lead-to-sale result in DA's own data.** Store `campaign_id`, `adset_id` and `ad_id` on each lead through Meta's dynamic URL parameters (§5.7, [H29]).
- **Needed from Matthew:** how long after the click each stage happens, specifically the share of completed consultations and of sales that fall within 7 days of the lead [I4].

### 0.4 (c) Does "Maximize number of qualified leads" work with website forms today, and what to check

**The Help Center says yes:**
- The goal "can be used with both website forms and instant forms", and "lead ads that use the maximize number of qualified leads for their website forms saw 9.5% lower cost per quality leads" [H1].
- The goals-by-location table lists it under Leads → **Website**, **Website and calls**, **Website and instant forms** and **Instant forms** [H2].
- The CRM FAQ (Mar 24, 2026) says "Instant Forms or Website Forms" and "Send the lead_id or PII for matching" [D16].

**The developer docs say no:**
- CRM overview (Dec 17, 2025): "currently only compatible with Facebook/Instagram's Lead Ads (Instant Forms)" [D13].
- Payload specification (Jun 28, 2026): "only supports native Lead Ads (Instant Forms)" [D14].
- Developer Implementation (Jun 28, 2026) [D15]:
  - "Refrain from using this payload specification for other event types, such as website leads";
  - "If events … do not match a Meta lead ad, then they will not be recognized for the integration and will not be used for training the model."
- The CRM test flow asks you to pick an **instant form** to generate a test lead [H21].

**Other clues:**
- The Marketing API ad-set fields include `lead_ads_form_event_source_type` with values `offsite_crm` and `offsite_web` [D26]. That suggests website ("offsite") forms are modelled, but the semantics are undocumented.
- Since April 2026 the goal needs a Conversions API integration for new campaigns [H1].

**Status: UNVERIFIED for DA.** Check it in DA's own account.

**Gate 0 checklist.** Run it in DA's ad account once access exists. Save drafts only, **never publish**, and screenshot every screen with the date.

**Gate 0-A: is the qualified-leads goal available on Website?**
1. In Ads Manager, click **+ Create** → **Leads** → Continue. Note whether the Advantage+ leads set-up or the manual set-up opens.
2. At the ad set, go to **Conversion** and set **Conversion location = Website**.
3. Open **Performance goal** and record the full list. Is **"Maximize number of qualified leads"** there, and can it be selected? If it is greyed out, record the tooltip text.
4. If it can be selected, choose it and record:
   - which dataset(s) it accepts (does it take `481578137302100`, or demand a separate CRM dataset?);
   - any **signal source** selector and its options (for example Meta source, CRM, website events);
   - which event counts as "the lead";
   - any **funnel-stage or target-stage** picker, and which events it lists;
   - every requirement or eligibility message (Conversions API or CRM integration, 200 leads a month, 7 days of data);
   - any window it states.
5. Repeat steps 2–4 for **Website and calls** and **Website and instant forms**. Record only; do not use them.
6. In Events Manager, open **Datasets** → `481578137302100` → **Settings**. Record whether there is a "Conversions API for CRM" or "CRM integration" section, a "Configure sales funnel" option, and the **data source category**.
7. Still in Events Manager, click **+ Connect data** → **CRM**. Does it let you choose the existing dataset `481578137302100`? Stop before confirming.
8. Discard the draft.

**Gate 0-B: which conversion events does a Website ad set offer?**
- With the performance goal set to maximize leads or conversions, open **Conversion event** and confirm `Schedule` is listed.
- Once the stage feed is live on the production dataset (rollout step R6), record whether `ConsultationCompleted`, `Purchase`, or a custom conversion built on `phone_call`/`other` events appears. Staging events go to a different dataset, so they won't show here.
- **Even if they appear, don't use them at launch.** Their eligibility is undocumented, and the 7-day window still applies.

**Gate 0-C (optional, only a DA admin, only with operator approval):** repeat Gate 0-A through the Marketing API with `execution_options=["validate_only"]`, which "will not perform the mutation but will run through the validation rules" [D25]. Use `optimization_goal=QUALITY_LEAD`, `destination_type=WEBSITE` and `promoted_object.pixel_id=481578137302100`.

**Decision rule:**
- If 0-A shows the goal for Website on dataset `481578137302100`, with a CRM or other downstream signal source, turn on **crm mode** (§6.6) and run it as a challenger cell against option A.
- Otherwise stay on option A (or B), and treat CRM stages as measurement only.

---

## 1. Architecture and data flow

```
 Meta ad click ─▶ https://dogacademy.org/...?fbclid=…&utm_source=facebook&campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}
                         │
 ┌─ BROWSER (dogacademy.org) ────────────────────────────────────────────────────────────────┐
 │ Pixel base code on EVERY layout (not on PSD/service-dog pages)                              │
 │   PageView · QuizStart* · PartialLead* · Lead · Schedule · Purchase(site checkout only)     │
 │   each non-PageView event: fbq('track'|'trackCustom', name, params, {eventID})              │
 │ First-party cookies on .dogacademy.org: _fbp, _fbc (set by pixel AND by server)            │
 └───────────────┬───────────────────────────────────────────────────────────────────────────┘
                 │ page GETs + Livewire POSTs (cookies, IP, user agent)
 ┌─ LARAVEL APP (AWS ELB → nginx → PHP) ───────────────────────────────────────────────────────┐
 │ CaptureMetaClickIds middleware (Meta Parameter Builder for PHP)                            │
 │   fbclid → _fbc (server-set if missing) · _fbp · first-touch UTMs/ad IDs → session         │
 │ Quiz component frontend.training.book-training                                              │
 │   email step → lead row + lead_meta_identities row · final submit → Lead                   │
 │ Scheduler (schedule-consultation) → first booking → Schedule                               │
 │ Site checkout (if used for TFL) → Purchase(website)                                         │
 │ Stage feed endpoint POST /api/meta/stage-events  ◀── CRM / dialer / billing (vendor TBD)   │
 │   (HMAC) + daily CSV/Sheet import + Zapier/Make fallback → lead_stage_events ledger        │
 │ meta_capi_events OUTBOX (event_id UNIQUE) ──after commit──▶ queue "meta-capi"             │
 │   SendMetaEvent job: 1 event/request, retries + backoff, 7-day guard, alerts               │
 └───────────────┬───────────────────────────────────────────────────────────────────────────┘
                 │ POST https://graph.facebook.com/v25.0/481578137302100/events  (system-user token)
 ┌─ META DATASET 481578137302100 (pixel = dataset) ────────────────────────────────────────────┐
 │ web events deduplicated on event_name + event_id (48 h)                                     │
 │ offline events (phone_call / other / system_generated): measurement + audiences             │
 │ [crm mode only] CRM stage events for "Maximize number of qualified leads"                   │
 └────────────────────────────────────────────────────────────────────────────────────────────┘
 * QuizStart and PartialLead are optional (P2).
```

**Rules for the architecture:**
- **One dataset.** Browser and server events both go to `481578137302100`. Meta: "Send events from the browser and your server to the same Meta Pixel ID" [D10].
- **One server sender.** Don't turn on Meta-enabled CAPI [H23], the Conversions API Gateway or any partner CAPI app for this dataset alongside this integration. No-code tools post to DA's webhook, never to Meta directly.
- **Redundant web set-up.** Every web conversion goes out from the browser *and* the server [H20][D10].
- **Timeline.** Meta estimates "2 to 4 weeks for new integrations" when you build directly [H23].

---

## 2. Event contract (summary; `event-contract.json` is canonical)

| # | Event | Trigger in DA's app | Sender | `action_source` | `event_id` | `user_data` | `custom_data` | Role |
|---|---|---|---|---|---|---|---|---|
| 1 | `PageView` | every page load where the pixel is allowed | Pixel | (browser) | none | pixel automatic | none | audiences |
| 2 | `QuizStart` (custom, P2) | quiz step 1 (zip) accepted | Pixel + CAPI | `website` | `da:quizstart:{quiz_session_id}` | web keys except email/phone/name | `content_name` | audiences |
| 3 | `PartialLead` (custom, P2) | email step saves the partial lead | Pixel + CAPI | `website` | `da:partiallead:{lead_id}` | web keys | `content_name` | retargeting |
| 4 | `Lead` | final quiz submission succeeds (the `loading:submit` handler) | Pixel + CAPI | `website` | `da:lead:{lead_id}` | web keys | `content_name`, `content_category`, `in_service_area` | measurement, audiences; **not** the optimization event |
| 5 | **`Schedule`** | the lead's **first** consultation booking, self-booked on the scheduler | Pixel + CAPI | `website` | `da:schedule:{lead_id}` | web keys | same three as Lead | **OPTIMIZATION EVENT** |
| 5b | `Schedule` | the first booking, made by an agent (from the stage feed) | CAPI | `phone_call` | `da:schedule:{lead_id}` | offline keys | same | measurement |
| 6 | `ConsultationCompleted` (custom) | CRM/dialer outcome "consultation held" (Matthew defines it) | CAPI | `phone_call` / `other` | `da:consult:{lead_id}` | offline keys | `content_name` | measurement (via an offline custom conversion); crm mode |
| 7 | `Purchase` | TFL first payment or signed contract, taken by phone, virtual terminal or hosted link | CAPI | `phone_call` / `other` | `da:purchase:{order_id}` | offline keys | `value`, `currency`, `order_id`, `content_name`, `content_category` | measurement, buyer audiences |
| 7b | `Purchase` | paid on dogacademy.org checkout | Pixel + CAPI | `website` | `da:purchase:{order_id}` | web keys | same | measurement |
| 8 | `SaleKept` (custom, optional) | 30-day full-refund window passed with no refund | CAPI | `system_generated` | `da:salekept:{order_id}` | offline keys | `value`, `currency`, `order_id` | measurement only |
| C1–C4 | `Lead Received`, `Consultation Scheduled`, `Consultation Completed`, `Sale Closed` | **crm mode only** (§6.6) | CAPI | `system_generated` | `da:crm:{stage}:{lead_id}` | CRM keys | `event_source=crm`, `lead_event_source` | qualified-leads training |

**Key sets:**
- **Web keys:** `em, ph, fn, ln, ct, st, zp, country, external_id, client_ip_address, client_user_agent, fbc, fbp`. Web events also carry `event_source_url` on dogacademy.org, which is required for website events [D1][D2].
- **Offline keys:** the same list without `client_ip_address` and `client_user_agent`. Those must be "the IP address [and user agent] of the browser corresponding to the event" [D3], and a phone call has no browser.
- **CRM keys:** `em, ph, fn, ln, ct, st, zp, country, external_id, fbc`. Click ID and hashed email have "Highest" priority for CRM events [D15].

**Value and currency** appear only on `Purchase` and `SaleKept`:
- the **actual TFL contract amount sold**, in USD, after discounts and before tax;
- sent **once** per order, including payment plans.

These values are internal. They are never shown in ads, and ads never state the price [I6].

---

## 3. Identity capture (quiz start → lead creation)

### 3.1 What is captured, and when

| Moment | Captured | Where it is stored |
|---|---|---|
| **Any GET with `?fbclid=`**, on any page including program pages without a pixel | fbclid, first-seen time in ms, `_fbc` built server-side if missing, `_fbp`, first-touch `utm_*`, `campaign_id`, `adset_id`, `ad_id`, landing path | `_fbc`/`_fbp` cookies (90 days, `.dogacademy.org`) + session `meta.*`. Also extend the existing `__ussa` cookie (400 days) to hold fbclid and the ad IDs [I1] |
| Quiz step 1 (zip) | zip → city and state (the server already resolves them [I2]) | quiz state |
| **Email step** (partial lead is saved [I2]) | create the `lead_meta_identities` row: fbc, fbp, fbclid, IP, user agent, quiz page URL, UTMs, consent flags | new table (§3.5) |
| **Final submit** | refresh IP, user agent, fbc and fbp from this request; the lead is completed | same row |
| **Booking** | IP, user agent, fbc and fbp from the booking request (used for this event only) | event payload |

### 3.2 fbclid → `_fbc`, using Meta's Parameter Builder for PHP

**The library exists.** Meta ships an open-source **Parameter Builder** with a server-side PHP SDK [D5]. It is on Packagist as `facebook/capi-param-builder-php`, version **1.3.2** [D6].
- For Laravel, the README says: `$param_builder->processRequestFromContext($request->server->all());`
- It then provides `getFbc()`, `getFbp()`, `getClientIpAddress()`, `getEventSourceUrl()`, `getReferrerUrl()` and `getNormalizedAndHashedPII()` [D6].
- It returns the cookies you should set. "The server-side SDK can't write cookies", so you set them yourself [D5].

**The `fbc` format** is `fb.{subdomainIndex}.{creationTimeMs}.{fbclid}`. Meta's rules [D4]:
- when you generate it on a server, `subdomainIndex = 1`;
- if no cookie is saved, "use the timestamp when you first observed or received this fbclid value";
- "We recommend sending the fbc parameter with every event."

**The appendix.** Parameter Builder adds an 8-character appendix (for example `.ABcDEFGh`). Send it exactly as returned [D5]. "Do not override or adjust the _fbc or _fbp cookie" [D5].

**Consent.** Meta says to make sure you have "the user's cookie consent before calling" [D5]. The opt-out handling is in §7.5.

### 3.3 Normalization and hashing (SHA-256, lowercase hex) [D3]

| Key | Rule | DA source | Test vector |
|---|---|---|---|
| `em` | trim, lowercase | step 18 email | `John_Smith@gmail.com` → `62a14e44f765419d10fea99367361a727c12365e2520f32218d505ed9aa0f62f` |
| `ph` | digits only, drop leading zeros, **include country code** (US 10 digits → `1` + 10) | step 19 phone | `(650)555-1212` → `16505551212` → `e323ec626319ca94ee8bff2e4c87cf613be6ea19919ed1364124e16807ab3176` |
| `fn`, `ln` | lowercase, no punctuation, UTF-8. DA collects one "name" field: `fn` = first token, `ln` = last token (omit `ln` if there is only one token) | step 17 name | `Mary` → `mary` |
| `ct` | lowercase, no punctuation, no special characters, **no spaces** | city resolved from the zip | `San Diego` → `sandiego` |
| `st` | 2-letter ANSI code, lowercase (map full state names to codes) | state resolved from the zip | `CA` → `ca` → `6959097001d10501ac7d54c0bdb8db61420f658f2922cc26e46d536119a31126` |
| `zp` | US: first 5 digits | step 1 zip | `94035-1234` → `94035` → `1794327a4d443904421f5ca1f3d72d0a62ba2f0770d80755ec2220fc0efb052e` |
| `country` | ISO 3166-1 alpha-2, lowercase. "Always include" it [D3] | constant | `us` → `79adb2a2fce5c6ba215fe5f27f532d4e7edbac4b6a5e09e1ef3a08084a904621` |
| `external_id` | "Hashing recommended". Use the same format on every channel [D3]. Value: `da-lead-{lead_id}`, hashed for CAPI. Optionally add a second value, `da-user-{user_id}` | DA lead ID | — |
| `client_ip_address` | not hashed; the real browser IP; IPv6 is preferred [D3] | `$request->ip()` behind trusted proxies | — |
| `client_user_agent` | not hashed; required for website events [D3] | request header | — |
| `fbc`, `fbp` | not hashed; send exactly as the cookie holds them [D4] | cookies or the stored identity | — |

Other rules:
- **Hash only once**, either in your own code or through Parameter Builder, never both [D5].
- **An event is rejected if its identifiers are too weak.** It is invalid if they are only `ct + country + st + zp + ge + client_user_agent`, only `db + client_user_agent`, only `fn + ge` or only `ln + ge` [D8]. Email or phone must always be present on Lead and later events.

### 3.4 `external_id`

- **Value:** `external_id = sha256("da-lead-{lead_id}")` on every server event for that lead.
- **In the pixel:** on pages where the visitor is a known lead, pass the same raw string `da-lead-{lead_id}` through advanced matching (§5.1). The pixel hashes automatically [D23].
- **Keep the format identical everywhere** [D3]. Whether the pixel hashes `external_id` specifically is **UNVERIFIED**. Check it in Test Events by confirming the key arrives.

### 3.5 Schema migration

This assumes Laravel 8 or later and MySQL 5.7 or later; adjust names to DA's schema. `leads` means DA's own lead table.

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateMetaCapiTables extends Migration
{
    public function up()
    {
        Schema::create('lead_meta_identities', function (Blueprint $t) {
            $t->id();
            $t->unsignedBigInteger('lead_id')->unique();            // DA lead ID
            $t->string('fbclid', 512)->nullable();
            $t->unsignedBigInteger('fbclid_first_seen_ms')->nullable();
            $t->string('fbc', 600)->nullable();
            $t->string('fbp', 255)->nullable();
            $t->string('client_ip', 45)->nullable();                // web events only
            $t->text('client_user_agent')->nullable();              // web events only
            $t->string('event_source_url', 1024)->nullable();       // quiz page URL, no query string
            $t->string('landing_path', 1024)->nullable();
            $t->json('utm')->nullable();                             // utm_* + campaign_id/adset_id/ad_id, first touch
            $t->boolean('is_meta_click')->default(false);
            $t->boolean('meta_opt_out')->default(false);             // GPC / "Do not share" → send nothing
            $t->boolean('meta_ldu')->default(false);                 // Limited Data Use flag (§7.5)
            $t->boolean('meta_excluded')->default(false);            // PSD / service-dog funnels (§7.3)
            $t->timestamps();
        });

        Schema::create('meta_capi_events', function (Blueprint $t) {  // outbox + sent ledger
            $t->id();
            $t->string('event_id', 191)->unique();
            $t->string('event_name', 64);
            $t->string('action_source', 32);
            $t->unsignedBigInteger('lead_id')->nullable()->index();
            $t->string('order_id', 128)->nullable()->index();
            $t->unsignedInteger('event_time');                       // Unix seconds, UTC
            $t->json('payload')->nullable();                         // hashed keys only; purged after 30 days
            $t->string('status', 16)->default('pending')->index();   // pending|sent|failed|dead|skipped
            $t->unsignedSmallInteger('attempts')->default(0);
            $t->unsignedTinyInteger('events_received')->nullable();
            $t->string('fbtrace_id', 64)->nullable();
            $t->integer('last_error_code')->nullable();
            $t->string('last_error', 500)->nullable();               // sanitized: no PII, no token
            $t->string('test_event_code', 64)->nullable();
            $t->timestamp('sent_at')->nullable();
            $t->timestamps();
            $t->index(['event_name', 'event_time']);
        });

        Schema::create('lead_stage_events', function (Blueprint $t) {  // inbound stage-feed ledger
            $t->id();
            $t->string('source', 16);                                  // crm|dialer|billing|zapier|make|csv
            $t->string('external_event_id', 191);
            $t->unsignedBigInteger('lead_id')->nullable()->index();
            $t->string('stage', 40);
            $t->dateTime('occurred_at');                               // UTC
            $t->string('channel', 16)->nullable();                     // phone|video|in_person
            $t->string('order_id', 128)->nullable();
            $t->unsignedBigInteger('amount_cents')->nullable();
            $t->char('currency', 3)->nullable();
            $t->string('product_line', 32)->nullable();
            $t->string('payment_channel', 16)->nullable();             // phone|website|payment_link|other
            $t->boolean('is_test')->default(false);
            $t->string('result', 32)->nullable();                      // queued|already_sent|ignored_negative|too_old|unmatched|excluded
            $t->timestamps();
            $t->unique(['source', 'external_event_id']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('lead_stage_events');
        Schema::dropIfExists('meta_capi_events');
        Schema::dropIfExists('lead_meta_identities');
    }
}
```

Two rules for these tables:
- **Never store call notes, recordings, transcripts or card data**, in these tables or in logs.
- The outbox `payload` holds only hashed identifiers plus IP and user agent. It is nulled after 30 days by `meta:capi:prune`.

---

## 4. Laravel implementation

The code is PHP 7.4 compatible, which the Parameter Builder also needs (PHP ≥ 7.4 [D6]). It assumes Laravel 8 or later, and a queue plus cache that support `ShouldBeUnique` (Redis or database).

### 4.1 Environment and config

`.env` (secrets live in AWS Secrets Manager or SSM and are injected at deploy; never in git, tickets, chat or logs):

```dotenv
META_CAPI_ENABLED=false                 # flip per environment after QA
META_PIXEL_ENABLED=true
META_PIXEL_ID=481578137302100           # staging uses <STAGING_DATASET_ID>
META_CAPI_ACCESS_TOKEN=<SYSTEM_USER_TOKEN>
META_GRAPH_API_VERSION=v25.0            # latest; released 2026-02-18 [D27]
META_TEST_EVENT_CODE=                   # staging only, e.g. <TEST_EVENT_CODE>; empty in production
META_TEST_LEAD_EMAIL_REGEX=             # production test lead only, e.g. /^capi-test\+\d{8}@<TEAM_DOMAIN>$/
META_TEST_EVENT_CODE_PROD=              # used only for events of a lead matching the regex
META_DOMAIN_VERIFICATION=<DOMAIN_VERIFICATION_CODE>
META_STAGE_FEED_MODE=offline            # offline | crm (crm only after Gate 0 passes)
META_CRM_LEAD_EVENT_SOURCE=<DA_CRM_NAME>
META_STAGE_WEBHOOK_SECRET=<HMAC_SECRET>
META_STAGE_WEBHOOK_BEARER=<OPTIONAL_BEARER_FOR_NO_CODE_TOOLS>
META_LDU_MODE=optout_suppress           # optout_suppress | optout_ldu (§7.5)
META_ALERT_SLACK_WEBHOOK=<SLACK_WEBHOOK_URL>
```

`config/meta.php`:

```php
<?php

return [
    'enabled'                => (bool) env('META_CAPI_ENABLED', false),
    'pixel_enabled'          => (bool) env('META_PIXEL_ENABLED', true),
    'pixel_id'               => env('META_PIXEL_ID', '481578137302100'),
    'access_token'           => env('META_CAPI_ACCESS_TOKEN'),
    'api_version'            => env('META_GRAPH_API_VERSION', 'v25.0'),
    'test_event_code'        => env('META_TEST_EVENT_CODE'),
    'test_lead_email_regex'  => env('META_TEST_LEAD_EMAIL_REGEX'),
    'test_event_code_prod'   => env('META_TEST_EVENT_CODE_PROD'),
    'domain_verification'    => env('META_DOMAIN_VERIFICATION'),
    'cookie_domains'         => ['dogacademy.org'],
    'stage_feed_mode'        => env('META_STAGE_FEED_MODE', 'offline'),
    'crm_lead_event_source'  => env('META_CRM_LEAD_EVENT_SOURCE', 'In-house CRM'),
    'stage_webhook_secret'   => env('META_STAGE_WEBHOOK_SECRET'),
    'stage_webhook_bearer'   => env('META_STAGE_WEBHOOK_BEARER'),
    'ldu_mode'               => env('META_LDU_MODE', 'optout_suppress'),
    'alert_slack_webhook'    => env('META_ALERT_SLACK_WEBHOOK'),
    // No pixel and no CAPI for these paths (health-related funnels, see §7.3):
    'excluded_paths'         => ['pages/*service-dog*', 'pages/*psychiatric*'],
];
```

### 4.2 Two framework gotchas: cookie encryption and proxies

1. **Cookie encryption.** Laravel's `EncryptCookies` decrypts every incoming cookie and encrypts every outgoing one. The pixel's plain `_fbp`/`_fbc` would then read as empty, and cookies set by the server would reach the pixel encrypted. Exclude them:

   ```php
   // app/Http/Middleware/EncryptCookies.php
   protected $except = ['_fbc', '_fbp', '_fbi'];
   ```

2. **Proxies.** The app sits behind an AWS ELB and nginx [I1]. Set `App\Http\Middleware\TrustProxies::$proxies` to the ELB/VPC CIDR, or `'*'` only if the instances can be reached solely through the ELB. `$request->ip()` then returns the visitor's IP, not the load balancer's.
   - Cloudflare only proxies `www`. If the apex is ever proxied, read `CF-Connecting-IP`.

### 4.3 Middleware: capture the click ID on every page

Register it at the end of the `web` middleware group, *inside* `StartSession`.

```php
<?php

namespace App\Http\Middleware;

use App\Services\Meta\MetaConsent;
use Closure;
use FacebookAds\ParamBuilder;
use Illuminate\Http\Request;

class CaptureMetaClickIds
{
    public function handle(Request $request, Closure $next)
    {
        if (! config('meta.pixel_enabled') || ! $request->isMethod('GET') || $request->ajax()
            || app(MetaConsent::class)->optedOut($request)) {
            return $next($request);
        }

        $builder = new ParamBuilder(config('meta.cookie_domains'));
        $cookiesToSet = $builder->processRequestFromContext($request->server->all());   // Laravel form per [D6]

        $session = $request->session();
        $fbclid = (string) $request->query('fbclid', '');
        if ($fbclid !== '' && ! $session->has('meta.fbclid')) {
            $session->put('meta.fbclid', $fbclid);
            $session->put('meta.fbclid_first_seen_ms', (int) floor(microtime(true) * 1000));
            $session->put('meta.landing_path', '/' . ltrim($request->path(), '/'));
        }
        if ($builder->getFbc()) {
            $session->put('meta.fbc', $builder->getFbc());
        }
        if ($builder->getFbp()) {
            $session->put('meta.fbp', $builder->getFbp());
        }
        $touch = array_filter($request->only([
            'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
            'campaign_id', 'adset_id', 'ad_id',
        ]));
        if ($touch && ! $session->has('meta.utm')) {
            $session->put('meta.utm', $touch);                                         // first touch wins
        }

        $response = $next($request);

        foreach ($cookiesToSet as $c) {
            // httpOnly MUST be false: the pixel JavaScript reads _fbc/_fbp.
            $response->headers->setCookie(cookie(
                $c->name, $c->value, (int) ceil($c->max_age / 60), '/', $c->domain, true, false, false, 'Lax'
            ));
        }

        return $response;
    }
}
```

The session is only 2 hours long [I1], so also write `fbclid`, `fbclid_first_seen_ms` and the three ad IDs into DA's existing `__ussa` attribution cookie. Today it is **not** written for fbclid-only visits [I1].

### 4.4 Normalizer, user-data builder and payload guard

```php
<?php

namespace App\Services\Meta;

use Illuminate\Support\Str;

final class MetaNormalizer
{
    public static function email($v)
    {
        $v = strtolower(trim((string) $v));
        return filter_var($v, FILTER_VALIDATE_EMAIL) ? $v : null;
    }

    public static function phoneUs($v)
    {
        $d = ltrim((string) preg_replace('/\D+/', '', (string) $v), '0');
        if (strlen($d) === 10) {
            $d = '1' . $d;
        }
        return (strlen($d) === 11 && $d[0] === '1') ? $d : null;
    }

    public static function splitName($full)
    {
        $parts = preg_split('/\s+/u', trim((string) $full), -1, PREG_SPLIT_NO_EMPTY) ?: [];
        $first = isset($parts[0]) ? $parts[0] : null;
        $last = count($parts) > 1 ? $parts[count($parts) - 1] : null;
        return [self::namePart($first), self::namePart($last)];
    }

    public static function namePart($v)
    {
        $v = (string) preg_replace('/[^\p{L}\p{M}]+/u', '', mb_strtolower(trim((string) $v), 'UTF-8'));
        return $v !== '' ? $v : null;
    }

    public static function city($v)
    {
        $v = (string) preg_replace('/[^a-z]/', '', strtolower(Str::ascii((string) $v)));
        return $v !== '' ? $v : null;
    }

    public static function state($v)
    {
        $v = strtolower(trim((string) $v));
        return preg_match('/^[a-z]{2}$/', $v) ? $v : null;
    }

    public static function zip($v)
    {
        $d = (string) preg_replace('/\D+/', '', (string) $v);
        return strlen($d) >= 5 ? substr($d, 0, 5) : null;
    }

    public static function sha256($normalized)
    {
        return ($normalized === null || $normalized === '') ? null : [hash('sha256', $normalized)];
    }
}
```

```php
<?php

namespace App\Services\Meta;

use Illuminate\Http\Request;

final class MetaUserData
{
    /**
     * @param object       $lead      DA lead (id, email, phone, name, city, state, zip_code)
     * @param object|null  $identity  lead_meta_identities row
     * @param Request|null $request   pass ONLY for website events: the browser request that performed the action
     */
    public static function build($lead, $identity = null, Request $request = null)
    {
        list($fn, $ln) = MetaNormalizer::splitName($lead->name);

        $ud = [
            'em'          => MetaNormalizer::sha256(MetaNormalizer::email($lead->email)),
            'ph'          => MetaNormalizer::sha256(MetaNormalizer::phoneUs($lead->phone)),
            'fn'          => MetaNormalizer::sha256($fn),
            'ln'          => MetaNormalizer::sha256($ln),
            'ct'          => MetaNormalizer::sha256(MetaNormalizer::city($lead->city)),
            'st'          => MetaNormalizer::sha256(MetaNormalizer::state($lead->state)),
            'zp'          => MetaNormalizer::sha256(MetaNormalizer::zip($lead->zip_code)),
            'country'     => MetaNormalizer::sha256('us'),
            'external_id' => MetaNormalizer::sha256('da-lead-' . $lead->id),
            'fbc'         => $identity ? $identity->fbc : null,
            'fbp'         => $identity ? $identity->fbp : null,
        ];

        if ($request !== null) {                                   // website events only
            $ud['client_ip_address'] = $request->ip();
            $ud['client_user_agent'] = (string) $request->userAgent();
            $ud['fbc'] = $request->cookie('_fbc') ?: $ud['fbc'];      // raw cookie (excluded from encryption)
            $ud['fbp'] = $request->cookie('_fbp') ?: $ud['fbp'];
        }

        return array_filter($ud, static function ($v) {
            return $v !== null && $v !== '' && $v !== [];
        });
    }
}
```

```php
<?php

namespace App\Services\Meta;

final class MetaPayloadGuard
{
    const USER_DATA_WEB = ['em', 'ph', 'fn', 'ln', 'ct', 'st', 'zp', 'country', 'external_id',
        'client_ip_address', 'client_user_agent', 'fbc', 'fbp'];
    const USER_DATA_OFFLINE = ['em', 'ph', 'fn', 'ln', 'ct', 'st', 'zp', 'country', 'external_id', 'fbc', 'fbp'];
    const CUSTOM_DATA = ['content_name', 'content_category', 'value', 'currency', 'order_id',
        'in_service_area', 'event_source', 'lead_event_source'];
    const FORBIDDEN = '/psychiatric|service[\s_-]?dog|\bpsd\b|\besa\b|emotional[\s_-]?support|veteran|military|disab|ptsd|insurance|medical|diagnos|therap/i';

    public static function clean(array $e)
    {
        $web = (isset($e['action_source']) ? $e['action_source'] : null) === 'website';
        $e['user_data'] = array_intersect_key(isset($e['user_data']) ? $e['user_data'] : [],
            array_flip($web ? self::USER_DATA_WEB : self::USER_DATA_OFFLINE));
        $e['custom_data'] = array_intersect_key(isset($e['custom_data']) ? $e['custom_data'] : [],
            array_flip(self::CUSTOM_DATA));
        if (! $e['custom_data']) {
            unset($e['custom_data']);
        }

        $probe = json_encode([$e['event_name'], isset($e['custom_data']) ? $e['custom_data'] : [],
            isset($e['event_source_url']) ? $e['event_source_url'] : '']);
        if (preg_match(self::FORBIDDEN, (string) $probe)) {
            throw new \DomainException('Blocked by sensitive-term guard: ' . $e['event_id']);
        }
        if ($web && (empty($e['event_source_url']) || empty($e['user_data']['client_user_agent']))) {
            throw new \DomainException('Website events need event_source_url and client_user_agent: ' . $e['event_id']);
        }
        if (empty($e['user_data']['em']) && empty($e['user_data']['ph']) && empty($e['user_data']['fbc'])
            && empty($e['user_data']['fbp']) && empty($e['user_data']['external_id'])) {
            throw new \DomainException('No usable match key: ' . $e['event_id']);
        }
        return $e;
    }
}
```

### 4.5 Event object, recorder and outbox (idempotent)

```php
<?php

namespace App\Services\Meta;

final class MetaEvent
{
    public $server;          // exact server payload (one element of "data")
    public $browser;         // detail for the browser twin, or null for server-only events
    public $leadId;
    public $orderId;
    public $testEventCode;

    const STANDARD = ['PageView', 'Lead', 'Schedule', 'Purchase', 'Contact', 'CompleteRegistration'];

    public function __construct(array $server, $browserTwin, $leadId = null, $orderId = null, $testEventCode = null)
    {
        $this->server = MetaPayloadGuard::clean($server);
        $this->browser = $browserTwin ? [
            'name'    => $this->server['event_name'],
            'custom'  => ! in_array($this->server['event_name'], self::STANDARD, true),
            'eventId' => $this->server['event_id'],                       // == server event_id (dedup key)
            'params'  => isset($this->server['custom_data']) ? $this->server['custom_data'] : new \stdClass(),
        ] : null;                                                          // identical custom_data both sides
        $this->leadId = $leadId;
        $this->orderId = $orderId;
        $this->testEventCode = $testEventCode ?: (config('meta.test_event_code') ?: null);
    }
}
```

```php
<?php

namespace App\Services\Meta;

use App\Jobs\SendMetaEvent;
use App\Models\MetaCapiEvent;

final class MetaEventRecorder
{
    /** Writes the outbox row (idempotent on event_id) and queues the send after the DB commit. */
    public function record(MetaEvent $event, $identity = null)
    {
        if (! config('meta.enabled')) {
            return null;
        }
        $s = $event->server;

        $status = 'pending';
        if ($identity && ($identity->meta_opt_out || $identity->meta_excluded)) {
            $status = 'skipped';                                           // audit trail, never sent
        } elseif ($identity && $identity->meta_ldu) {
            $s['data_processing_options'] = ['LDU'];                       // [D24]
            $s['data_processing_options_country'] = 0;
            $s['data_processing_options_state'] = 0;
        }

        $row = MetaCapiEvent::firstOrCreate(['event_id' => $s['event_id']], [
            'event_name'      => $s['event_name'],
            'action_source'   => $s['action_source'],
            'lead_id'         => $event->leadId,
            'order_id'        => $event->orderId,
            'event_time'      => $s['event_time'],
            'payload'         => $s,
            'status'          => $status,
            'test_event_code' => $event->testEventCode,
        ]);

        if ($row->wasRecentlyCreated && $status === 'pending') {
            SendMetaEvent::dispatch($row->id)->afterCommit();
        }
        return $row;
    }

    /** Browser twin in the same Livewire response (no redirect in this response). */
    public function browserNow($livewireComponent, MetaEvent $event)
    {
        if ($event->browser) {
            $livewireComponent->dispatchBrowserEvent('send-fb-event', $event->browser);
        }
    }

    /** Browser twin on the next page render (use when this response redirects). */
    public function browserNextPage(MetaEvent $event)
    {
        if ($event->browser) {
            session()->push('meta_browser_events', $event->browser);
        }
    }
}
```

`App\Models\MetaCapiEvent` needs `protected $casts = ['payload' => 'array'];` and `protected $guarded = [];`.

**Idempotency:**
- `event_id` is deterministic and `UNIQUE` in the outbox, so a replay of the same business event never creates a second send.
- A retried job reuses the same row and the same `event_id`.

**Event factory (one example; the other events follow `event-contract.json`):**

```php
<?php

namespace App\Services\Meta;

use Illuminate\Http\Request;

final class MetaEvents
{
    public static function lead($lead, $identity, Request $request)
    {
        return new MetaEvent([
            'event_name'       => 'Lead',
            'event_time'       => $lead->completed_at->getTimestamp(),
            'event_id'         => 'da:lead:' . $lead->id,
            'action_source'    => 'website',
            'event_source_url' => $identity->event_source_url
                ?: 'https://dogacademy.org/in-person-dog-training/book-training',
            'user_data'        => MetaUserData::build($lead, $identity, $request),
            'custom_data'      => self::leadParams($lead),
        ], true, $lead->id, null, self::testCodeFor($lead));
    }

    public static function scheduleWeb($lead, $identity, Request $request, $bookedAtTs)
    {
        return new MetaEvent([
            'event_name'       => 'Schedule',
            'event_time'       => (int) $bookedAtTs,
            'event_id'         => 'da:schedule:' . $lead->id,                 // first booking per lead only
            'action_source'    => 'website',
            'event_source_url' => 'https://dogacademy.org/custom-training-program/schedule-consultation',
            'user_data'        => MetaUserData::build($lead, $identity, $request),
            'custom_data'      => ['content_name' => 'free_consultation'] + self::leadParams($lead, false),
        ], true, $lead->id, null, self::testCodeFor($lead));
    }

    public static function purchaseOffline($lead, $identity, $order, $actionSource)
    {
        return new MetaEvent([
            'event_name'    => 'Purchase',
            'event_time'    => (int) $order->paid_at_ts,
            'event_id'      => 'da:purchase:' . $order->id,
            'action_source' => $actionSource,                                  // phone_call | other
            'user_data'     => MetaUserData::build($lead, $identity, null),      // no IP/UA for offline
            'custom_data'   => [
                'value'            => round((float) $order->amount, 2),
                'currency'         => 'USD',
                'order_id'         => (string) $order->id,
                'content_name'     => 'trained_for_life',
                'content_category' => $order->product_line === 'tfl_virtual' ? 'virtual' : 'in_person',
            ],
        ], false, $lead->id, (string) $order->id, self::testCodeFor($lead));
    }

    private static function leadParams($lead, $withName = true)
    {
        $p = [
            'content_category' => $lead->program_category,                    // private|group|online|mixed
            'in_service_area'  => $lead->in_service_area ? 'yes' : 'no',      // omit if DA can't compute it
        ];
        return $withName ? ['content_name' => 'book_training_quiz'] + $p : $p;
    }

    private static function testCodeFor($lead)
    {
        $re = config('meta.test_lead_email_regex');
        return ($re && preg_match($re, (string) $lead->email)) ? config('meta.test_event_code_prod') : null;
    }
}
```

### 4.6 `CapiService`: the exact Graph API request

The request, as documented [D7]:

```
POST https://graph.facebook.com/v25.0/481578137302100/events
Content-Type: application/x-www-form-urlencoded

data=<JSON array of 1..1000 server events>          # we send 1 event per request
access_token=<SYSTEM_USER_TOKEN>                     # form body, never in the URL or logs
test_event_code=<TEST_EVENT_CODE>                    # staging / the labelled test lead only
```

The responses:
- **Success:** `{"events_received":1,"messages":[],"fbtrace_id":"<FBTRACE_ID>"}`
- **Error:** `{"error":{"message":"…","type":"OAuthException","code":190,"error_subcode":<N>,"fbtrace_id":"<FBTRACE_ID>"}}`

The batch rules: "You can send up to 1,000 events in data" and "If any event you send in a batch is invalid, we reject the entire batch" [D7]. Sending **one event per request** means one bad event can never drop another.

```php
<?php

namespace App\Services\Meta;

use Illuminate\Support\Facades\Http;

final class CapiService
{
    /** @return array{ok:bool,http:int,events_received:?int,fbtrace_id:?string,code:?int,message:?string} */
    public function send(array $serverEvent, $testEventCode = null)
    {
        $url = sprintf('https://graph.facebook.com/%s/%s/events', config('meta.api_version'), config('meta.pixel_id'));
        $form = [
            'data'         => json_encode([$serverEvent], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
            'access_token' => config('meta.access_token'),
        ];
        if ($testEventCode) {
            $form['test_event_code'] = $testEventCode;
        }

        try {
            $r = Http::asForm()->withOptions(['connect_timeout' => 5])->timeout(15)->post($url, $form);
        } catch (\Throwable $e) {                                   // network error / timeout → retryable
            return ['ok' => false, 'http' => 0, 'events_received' => null, 'fbtrace_id' => null,
                'code' => null, 'message' => 'transport: ' . get_class($e)];
        }

        $j = $r->json() ?: [];
        $err = isset($j['error']) ? $j['error'] : [];
        return [
            'ok'              => $r->successful() && isset($j['events_received']),
            'http'            => $r->status(),
            'events_received' => isset($j['events_received']) ? (int) $j['events_received'] : null,
            'fbtrace_id'      => isset($j['fbtrace_id']) ? $j['fbtrace_id'] : (isset($err['fbtrace_id']) ? $err['fbtrace_id'] : null),
            'code'            => isset($err['code']) ? (int) $err['code'] : null,
            'message'         => isset($err['message']) ? mb_substr($err['message'], 0, 300) : null,
        ];
    }

    /** Transient Graph errors worth retrying (common codes; confirm against Meta's error reference). */
    public static function retryable(array $res)
    {
        return $res['http'] === 0 || $res['http'] >= 500
            || in_array($res['code'], [1, 2, 4, 17, 32, 341, 613, 80004], true);
    }
}
```

Error handling:
- **Code `190`** (token invalid or expired) and **`200`/`10`** (permissions) alert right away.
- **Code `100`** (invalid parameter) goes straight to `dead` with no retry.
- The retry list above is based on common Graph error codes and is **UNVERIFIED** against Meta's current error reference.

### 4.7 The queued job: retries, backoff, the 7-day guard, idempotency

```php
<?php

namespace App\Jobs;

use App\Models\MetaCapiEvent;
use App\Services\Meta\CapiService;
use App\Services\Meta\MetaAlerts;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;

class SendMetaEvent implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public $tries = 10;
    public $uniqueFor = 3600;
    public $outboxId;

    public function __construct($outboxId)
    {
        $this->outboxId = (int) $outboxId;
        $this->onQueue('meta-capi');
    }

    public function uniqueId()
    {
        return 'meta-capi-' . $this->outboxId;
    }

    public function backoff()
    {
        return [10, 30, 60, 120, 300, 600, 1800, 3600, 7200];      // ≈3.8 h total, then failed()
    }

    public function handle(CapiService $capi)
    {
        $row = MetaCapiEvent::find($this->outboxId);
        if (! $row || in_array($row->status, ['sent', 'dead', 'skipped'], true)) {
            return;                                                  // idempotent
        }
        if (time() - $row->event_time > 7 * 86400 - 3600) {          // Meta rejects the whole request if older than 7 days [D1]
            $row->update(['status' => 'dead', 'last_error' => 'too_old_for_meta']);
            MetaAlerts::dead($row);
            return;
        }

        $res = $capi->send($row->payload, $row->test_event_code);
        $row->increment('attempts');

        if ($res['ok'] && $res['events_received'] === 1) {
            $row->update(['status' => 'sent', 'events_received' => 1, 'fbtrace_id' => $res['fbtrace_id'],
                'sent_at' => now(), 'last_error' => null, 'last_error_code' => null]);
            return;
        }

        $row->update(['status' => 'failed', 'last_error_code' => $res['code'],
            'last_error' => $res['message'], 'fbtrace_id' => $res['fbtrace_id']]);

        if (in_array($res['code'], [190, 200, 10], true)) {
            MetaAlerts::auth($row, $res);                           // token/permission problem: page a human
        }
        if (CapiService::retryable($res) || in_array($res['code'], [190, 200, 10], true)) {
            throw new \RuntimeException('meta_capi_retryable:' . $row->event_id);   // uses backoff()
        }

        $row->update(['status' => 'dead']);                          // e.g. code 100: fix payload, never retry blindly
        MetaAlerts::dead($row);
    }

    public function failed(\Throwable $e)
    {
        $row = MetaCapiEvent::find($this->outboxId);
        if ($row && $row->status !== 'sent') {
            $row->update(['status' => 'dead']);
            MetaAlerts::dead($row);
        }
    }
}
```

**Housekeeping commands** (register them in `app/Console/Kernel.php`):
- **`meta:capi:flush`** runs every 5 minutes. It re-dispatches rows that are `pending` or `failed`, were last updated more than 2 minutes ago, and have `event_time` within the last 6 days 23 hours. This is the safety net if a dispatch or queue was lost; the outbox pattern means nothing is dropped.
- **`meta:capi:prune`** runs daily and sets `payload = NULL` on rows older than 30 days. The metadata is kept.
- **`meta:capi:reconcile`** runs daily and prints the 7-day tables from `QA-RUNBOOK.md` §9.
- **`meta:import-stages {csv}`** is the fallback import (§6.4).

### 4.8 Hooks in DA's code

**Quiz component `frontend.training.book-training` (Livewire v2):**

```php
use App\Services\Meta\LeadMetaIdentityCapture;
use App\Services\Meta\MetaEventRecorder;
use App\Services\Meta\MetaEvents;

// (1) After the partial lead is saved at the "email" step (existing email_owner_submit_step hook):
try {
    $identity = app(LeadMetaIdentityCapture::class)->capture($lead, request());
    // optional P2: PartialLead event, same pattern as Lead
} catch (\Throwable $e) {
    \Log::channel('meta_capi')->warning('meta_identity_capture_failed', ['lead_id' => $lead->id, 'error' => get_class($e)]);
}

// (2) In the final-submission handler ("loading:submit"), after the lead is marked complete:
try {
    $identity = app(LeadMetaIdentityCapture::class)->capture($lead, request());
    $event = MetaEvents::lead($lead, $identity, request());
    $recorder = app(MetaEventRecorder::class);
    $recorder->record($event, $identity);
    $recorder->browserNextPage($event);   // the flow redirects to schedule-consultation next;
                                          // use browserNow($this, $event) if this response does not redirect
} catch (\Throwable $e) {
    // Meta code must NEVER break the quiz or the booking. Log (no PII) and move on.
    \Log::channel('meta_capi')->warning('meta_event_build_failed', ['lead_id' => $lead->id, 'error' => get_class($e)]);
}
```

**Rule for every call site** (quiz, scheduler, checkout, webhook): wrap the Meta calls in `try/catch (\Throwable)`. A guard exception, such as a forbidden term or a missing match key, logs and skips the event. It never blocks the visitor or the lead record.

**`LeadMetaIdentityCapture::capture()`:**
- Upserts `lead_meta_identities` for the lead from:
  - the session (`meta.fbclid`, `meta.fbclid_first_seen_ms`, `meta.fbc`, `meta.fbp`, `meta.utm`, `meta.landing_path`);
  - the `__ussa` cookie fields;
  - the raw `_fbc`/`_fbp` cookies;
  - `$request->ip()` and `$request->userAgent()`.
- **`event_source_url`** is the page the visitor was on, taken from the `Referer` of the Livewire request. Keep only the path, only if the host is `dogacademy.org`, and fall back to `https://dogacademy.org/in-person-dog-training/book-training`.
  - **Gotcha:** inside a Livewire request, `$request->url()` is `/livewire/message/...`. That is **not** the page URL and must never be sent.
- **`is_meta_click`** is true when there is an fbclid or `_fbc`, or when `utm_source` is one of `facebook|fb|instagram|ig|meta`.
- **`meta_excluded`** is true for leads from the PSD or service-dog funnels (§7.3).
- **`meta_opt_out`** follows §7.5.

**Scheduler (`/custom-training-program/schedule-consultation`):** when the lead's **first** booking is created, record `MetaEvents::scheduleWeb($lead, $identity, request(), $booking->created_at->getTimestamp())`, inside the same `try/catch`. Then send the browser twin (`browserNow` inside a Livewire component, `browserNextPage` before a redirect).
- A reschedule sends nothing.
- A booking made by an agent arrives through the stage feed as `phone_call` (§6).
- **Dependency:** today this page redirects to `/login` when the session is missing [I2]. Give the post-quiz redirect a signed URL, for example `URL::temporarySignedRoute(..., now()->addHours(48), ...)`. Otherwise in-app-browser visitors who lose their session can never book, and the optimization event under-fires.

**DA checkout, if TFL is ever paid on dogacademy.org:**
- The order-confirmation page fires the browser `Purchase` with `eventID = 'da:purchase:{order_id}'`, `value` and `currency`.
- The server records the same event with `action_source=website` [D22].
- **Course purchases** (existing checkout) may send `Purchase` with `content_category=course`. TFL always uses `content_name=trained_for_life`, so custom conversions can split the two.

### 4.9 Browser/server deduplication

- **The browser call** is `fbq('track', name, params, {eventID})`, or `fbq('trackCustom', …)` for custom events [D9].
- **The rule.** The same `event_name` and the same ID must reach the same pixel. Meta deduplicates within 48 hours, and "If a server and browser/app event arrive at approximately the same time (that is, within 5 minutes of each other), we favor the browser/app event" [D1][D9].
- **Keep the parameters identical.** The browser twin carries **exactly the same `custom_data`** as the server event. If the browser event is the one kept, the `in_service_area` parameter must still be there for the custom-conversion rule.
- **Don't dedup on `external_id`/`fbp` alone.** That fallback "only works for deduplicating events sent first from the browser and then through the server" [D9].

### 4.10 Logging and alerts

**Logging:**
- Use a log channel `meta_capi` (daily, 30 days).
- Log only: `event_id`, `event_name`, `action_source`, status, HTTP code, Graph error code, `fbtrace_id` and `events_received`.
- **Never log** the token, the request body, raw PII or hashed PII.
- Scrub `access_token` in the exception reporter (for example Sentry `before_send`).

**Alerts (`MetaAlerts`, posting to the Slack webhook plus email):**
- **Immediately:** any `dead` event; any error `190`, `200` or `10`.
- **Hourly:** failed or dead above 2% of attempts.
- **During business hours:** zero `Lead` sends for 3 hours while quiz completions are above zero.
- **Daily digest:** counts per event name and status, p95 delay, and rows at risk of the 7-day limit.
- **Weekly:** AdMaker pulls the Dataset Quality API (QA-RUNBOOK §7).

### 4.11 Automated tests to ship with the build

- **Normalizer vectors** from §3.3.
- **Payload guard:**
  - unknown keys are dropped;
  - forbidden terms throw;
  - a website event without a user agent or URL throws;
  - an offline event carries no IP or user agent.
- **Deterministic `event_id`.**
- **The job, using `Http::fake`:**
  - `events_received=1` → sent;
  - `500` → retried;
  - `100` → dead with no retry;
  - `190` → alert;
  - an 8-day-old event → dead and never sent.
- **The webhook (§6):**
  - a bad signature → 401;
  - a replay → `duplicate`;
  - a refund → stored but not sent;
  - a purchase with no prior consultation → the consultation is backfilled first.
- **A log scan:** no `access_token` and no `@` characters in `storage/logs/meta_capi*`.

---

## 5. Fixing the current pixel

### 5.1 Base code on every layout

Create `resources/views/partials/meta-head.blade.php` and include it in the `<head>` of **every** Blade layout: standard, booking, **program pages** (private, group, online), city pages, the scheduler, register and login. Remove every other hard-coded copy of `fbq('init')` so the pixel is never initialized twice.

```blade
<meta name="facebook-domain-verification" content="{{ config('meta.domain_verification') }}" />
@php
    $metaConsent = app(\App\Services\Meta\MetaConsent::class);
    $metaAllowed = config('meta.pixel_enabled')
        && ! \Illuminate\Support\Str::is(config('meta.excluded_paths'), request()->path())
        && ! $metaConsent->optedOut(request());
    $metaAm = $metaAllowed ? app(\App\Services\Meta\MetaAdvancedMatching::class)->forCurrentVisitor() : [];
    $metaQueue = $metaAllowed ? session()->pull('meta_browser_events', []) : [];
@endphp
@if ($metaAllowed)
<script>
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
@if ($metaConsent->ldu(request()))
fbq('dataProcessingOptions', ['LDU'], 0, 0);
@endif
fbq('init', '{{ config('meta.pixel_id') }}', @json($metaAm ?: new \stdClass()));
fbq('track', 'PageView');
window.__daMetaQueue = @json($metaQueue);
</script>
<noscript><img height="1" width="1" style="display:none" alt=""
src="https://www.facebook.com/tr?id={{ config('meta.pixel_id') }}&ev=PageView&noscript=1"/></noscript>
<script src="/assets/js/tk-fb.js" defer></script>  {{-- keep DA's mix() versioning --}}
@endif
```

- **Advanced matching.** `MetaAdvancedMatching::forCurrentVisitor()` returns the *normalized, unhashed* values the pixel hashes itself [D23]: `em, ph, fn, ln, ct, st, zp, country` and `external_id='da-lead-{id}'`.
  - Only do this for the signed-in lead's own pages.
  - Serve those pages with `Cache-Control: private, no-store`.
- **LDU.** `fbq('dataProcessingOptions', ['LDU'], 0, 0)` has to come before `init` [D24].

### 5.2 Pages that must **not** carry the pixel (or send CAPI)

- **Which pages:** `/pages/*psychiatric*` and `/pages/*service-dog*`, the PSD landing pages and consultation forms. Today `/pages/service-dog-training-consult` fires the pixel [I1].
- **Why:** Meta prohibits sharing information about "mental health and psychological states" and "physical locations that identify a health condition". It also says you can "remove it [the pixel] from the specific pages where you don't want to continue using it" [H25].
- **The risk:** Meta may put a dataset in *core setup*, which strips custom parameters and URL paths [H26]. It may also apply a category that restricts "specific mid and lower funnel events" [H27]. Either one would break `Schedule` optimization.
- The WordPress sections (`/blog/`, `/adoption/`, `/puppies-for-sale/`) stay pixel-free as they are.

### 5.3 Domain verification

- **Where the tag is needed.** The partial puts the verification `<meta>` tag on every layout. That is harmless, but Meta only reads the **home page**, and the tag must be static HTML because the crawler doesn't run JavaScript [H24].
- **Durable method:** a **DNS TXT** record on `dogacademy.org`, whose DNS is on Cloudflare [H24].
- **Subdomains are covered.** Verifying the root domain covers subdomains [H24].
- **Removing it afterwards.** "once your domain has been verified, you can remove the meta-tags, DNS TXT records and HTML files" [H24].
- **Check first.** Meta says another Business may already have verified it. The shared USSA platform makes that plausible [I1]. Confirm the current tag value belongs to DA's Business.

### 5.4 Replace `/assets/js/tk-fb.js`

```js
// /assets/js/tk-fb.js (v2): every non-PageView event carries eventID; params identical to the server event.
(function () {
  function send(d) {
    if (typeof window.fbq !== 'function' || !d || !d.name) { return; }
    var params = d.params || {};
    var opts = d.eventId ? { eventID: d.eventId } : {};
    if (d.custom) { window.fbq('trackCustom', d.name, params, opts); }
    else { window.fbq('track', d.name, params, opts); }
  }
  window.addEventListener('send-fb-event', function (e) { send(e.detail); });
  (window.__daMetaQueue || []).forEach(send);   // events queued before a redirect
  window.__daMetaQueue = [];
})();
```

From Livewire v2: `$this->dispatchBrowserEvent('send-fb-event', $event->browser)`.

**Delete every legacy dispatch.** Find them with `grep -rn "send-fb-event\|SendFBEvent" app resources public/assets/js`. Today's name-only events [I1][I2] would otherwise double-count `Lead`. The labelled test lead in `../test-lead/` lists which names currently fire at which step.

### 5.5 Keep the fbclid through the quiz

- The middleware (§4.3) captures fbclid on **any** page, including the program pages. Only people who land directly on the booking form keep it today [I1].
- `_fbc` is set server-side when it is missing and lasts 90 days on `.dogacademy.org` [D4].
- Nothing depends on the Livewire `url` property any more.
- Optional: pass `?zip_code=` from the ad URL to skip step 1 [I2]. This is a funnel decision.

### 5.6 Heyflow (`training.dogacademy.org`)

- **Keep it off Meta.** It has no pixel, it shows the Google number [I2], and it is not a Meta destination.
- If it ever becomes one:
  - add the pixel through Heyflow's own settings;
  - send server `Lead` events from DA's existing `/api/v1/heyflow/submit` handler, with `event_source_url` on `training.dogacademy.org` and `fbc` built from the fbclid Heyflow passes through;
  - make it server-only unless Heyflow can pass a custom `eventID`, so nothing is counted twice.

### 5.7 Ad URL parameters

Every Meta ad URL should use:

`utm_source=facebook&utm_medium=paid_social&utm_campaign={{campaign.name}}&campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}` [H29]

Rules:
- **No health, PSD, service-dog or veteran terms** in any URL or UTM [H25].
- **Keep `utm_source=facebook`.** It is the only value that switches the phone number to the Facebook line [I1].
- **Accept Instagram too.** Meta may add `utm_source=instagram` automatically [H29], so DA's number-swap logic and `is_meta_click` should also accept `instagram|ig|fb|meta`.

---

## 6. Stage feed from the CRM, dialer and billing systems (vendors unknown)

### 6.1 Webhook endpoint (authenticated)

`POST https://dogacademy.org/api/meta/stage-events`. Use the `api` route group (no CSRF), `throttle:120,1` and `VerifyStageSignature`.

```json
{
  "source": "dialer",
  "event_id": "<SOURCE_UNIQUE_EVENT_ID>",
  "stage": "consultation_completed",
  "occurred_at": "2026-10-02T17:31:05Z",
  "lead": { "da_lead_id": "<DA_LEAD_ID>", "email": "<EMAIL_IF_NO_LEAD_ID>", "phone": "<PHONE_IF_NO_LEAD_ID>" },
  "consultation": { "channel": "phone", "duration_seconds": 1260 },
  "order": null,
  "test": false
}
```

**Field rules:**
- **`stage`** is one of: `consultation_scheduled`, `consultation_completed`, `purchase`, `refund`, `cancelled`, `no_show`, `disqualified`.
- **`order`** is required when `stage=purchase`:
  `{ "order_id": "<ORDER_ID>", "amount": 1195.00, "currency": "USD", "product_line": "tfl_in_person|tfl_virtual|other_training", "payment_channel": "phone|website|payment_link|other" }`
- **`occurred_at`** is ISO-8601 UTC and means the **real moment** of the stage change (call end, payment capture). It is not the upload time.
- **Unknown fields are dropped and never stored.** Any field that carries notes, comments, transcripts, recordings, military status, PSD or health information is **rejected**.

**Responses:**
- `202 {"status":"queued","meta_event_id":"da:consult:<DA_LEAD_ID>"}`
- `200 {"status":"duplicate"}`
- `200 {"status":"stored_not_sent","reason":"already_sent|negative_stage|too_old|excluded|unmatched"}`
- `401` for a bad signature
- `422` for validation errors

**Authentication.** HMAC-SHA256 over `timestamp + "." + raw_body`, with timestamp skew no more than 5 minutes. A fixed bearer token is allowed **only** for no-code tools that can't sign.

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class VerifyStageSignature
{
    public function handle(Request $request, Closure $next)
    {
        $secret = (string) config('meta.stage_webhook_secret');
        $ts = (string) $request->header('X-DA-Timestamp', '');
        $sig = (string) $request->header('X-DA-Signature', '');       // "sha256=<hex>"
        $expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $request->getContent(), $secret);

        $hmacOk = $secret !== '' && ctype_digit($ts) && abs(time() - (int) $ts) <= 300 && hash_equals($expected, $sig);
        $bearer = (string) config('meta.stage_webhook_bearer');
        $bearerOk = $bearer !== '' && hash_equals($bearer, (string) $request->bearerToken());

        abort_unless($hmacOk || $bearerOk, 401);
        return $next($request);
    }
}
```

### 6.2 Ingestion rules (`StageEventIngestor`)

1. **Validate.** Insert into `lead_stage_events` with a unique `(source, event_id)`. If it is a duplicate, return `duplicate`.
2. **Resolve the lead.** Match on `da_lead_id`. Failing that, match the normalized email or phone to the **most recent lead created before `occurred_at`** within 60 days. If nothing matches, the result is `unmatched` and an alert goes out.
3. **Excluded and opted-out leads** are stored with result `excluded` and never sent.
4. **Negative stages** (`refund`, `cancelled`, `no_show`, `disqualified`) are stored with result `ignored_negative` and **never sent to Meta** (§7.1).
5. **Too old.** If `occurred_at` is more than 6 days 23 hours ago, the result is `too_old` and nothing is sent [D1][D7].
6. **Map to Meta events** in offline mode, the default:

   | Stage | Meta event | `action_source` | `event_id` | Notes |
   |---|---|---|---|---|
   | `consultation_scheduled` | `Schedule` | `phone_call` | `da:schedule:{lead_id}` | If the website `Schedule` already exists for the lead: result `already_sent`, nothing is sent |
   | `consultation_completed` | `ConsultationCompleted` | `phone_call` if `channel=phone`, otherwise `other` | `da:consult:{lead_id}` | First completion per lead only |
   | `purchase` | `Purchase` | `phone_call` if `payment_channel=phone`; `other` for `payment_link`/`other` | `da:purchase:{order_id}` | If `payment_channel=website`, the site checkout already emitted it: result `already_sent`. A webhook can never send a `website` event, because it has no browser user agent or URL [D2] |

7. **Backfill.** If a `purchase` arrives before any consultation event for the lead, first emit `ConsultationCompleted`. Use the consultation time if it was supplied, otherwise the purchase time minus 1 second. The sale is made on the consultation call [I3]. In crm mode this is required: "all previous stages should have been sent beforehand" [I3].
8. **Suppress PSD sales.** A `purchase` with `product_line` indicating PSD or service-dog training is never sent (§7.3).

### 6.3 Stage definitions DA must pin down (Matthew)

| Stage | Proposed definition | DA must confirm |
|---|---|---|
| Schedule | The lead's first consultation booking is created, by the lead or by an agent | Is self-booking the norm, or does the agent book? |
| ConsultationCompleted | The call was held with the owner or decision-maker and reached the recap or pitch | The disposition code, any minimum talk time, and phone vs video |
| Purchase | First successful payment or signed TFL contract | The value definition (contract total), and where payment is taken |
| SaleKept (optional) | 30 days after purchase with no refund (TFL: 100% refund within 30 days [I5]) | Where refunds are recorded |

### 6.4 Fallback A: daily CSV or Google Sheet import

- **Command:** `php artisan meta:import-stages storage/app/imports/stages-YYYY-MM-DD.csv --source=csv [--dry-run]`
- **Columns:** `event_id,stage,occurred_at,da_lead_id,email,phone,channel,order_id,amount,currency,product_line,payment_channel`. Each row runs through the same ingestor as the webhook.
- **Run it at least daily.** Rows older than 6 days 23 hours can never reach Meta [D1]. Meta's own advice for offline data is to "upload daily" [H13].
- **Google Sheets:** never publish the Sheet to the web, because it contains PII. Read it with a service account (read-only), or use Fallback B.

### 6.5 Fallback B: Zapier or Make

- **Flow:** trigger (CRM or dialer status change, or a new or updated Sheet row) → build the JSON above → **sign it** with HMAC (Code by Zapier, or Make's `sha256` HMAC function) or use the bearer token → POST to DA's endpoint.
- **Never map** notes or free-text fields.
- **Never use** a no-code "Conversions API" app that posts straight to Meta. That would create a second server sender (§1).

### 6.6 crm mode (only if Gate 0-A passes)

`META_STAGE_FEED_MODE=crm`. Web `Lead` and `Schedule` stay as they are. The app then also emits CRM stage events to the same dataset.

**The four stage events and when they fire:**

| Event | When |
|---|---|
| `Lead Received` | at lead completion, with `event_time = lead time + 1 s` |
| `Consultation Scheduled` | first booking |
| `Consultation Completed` | consultation held |
| `Sale Closed` | sale |

**Payload for every CRM stage event** [D14][D15]:
- `action_source: system_generated`
- `custom_data: {"event_source":"crm","lead_event_source":"<DA_CRM_NAME>"}`
- `user_data`: the CRM keys (§2). There is no `lead_id`, because website leads don't have one.

**Rules:**
- Each stage is sent once, the first time it is reached.
- **Event names are fixed** once the funnel is configured. They are free-form and must not collide with the web names [I3].
- **Replacement.** In crm mode, `Consultation Completed` replaces the offline `ConsultationCompleted`. `Purchase` (standard) is still sent for audiences and value.
- **Reporting.** Never add `Sale Closed` and `Purchase` together.

**Meta's documented gates** (for Instant Forms) [I3][D16]:
- 200 leads a month;
- lead coverage of at least 60%;
- at least 7 days of data;
- the target stage at 1–40% of leads within 28 days;
- uploads at least daily.

---

## 7. Rules

### 7.1 No negative events

- Never send refunds, no-shows, cancellations, disqualifications or "lost".
- If a partner forwards every status, it goes into `lead_stage_events` and stops there.

### 7.2 Refunds: the "sale kept" option

Refunds are the most common in-person complaint [I5]. Three choices:

1. **Do nothing.** `Purchase` stays as sent. This is the simplest option.
2. **Optional `SaleKept`.** A custom event with `action_source=system_generated`, sent at day 30 when no refund was recorded, with `event_time` = the day the refund window closed.
   - For offline data Meta says "you can't use custom events for optimization or custom audiences"; they only feed custom conversions and attribution [H12].
   - It lands about 30 days after the click, outside every optimization window.
   - So it gives Ads Manager a "kept revenue" figure, nothing more.
3. **Recommended for audiences:** a weekly **customer-list** audience of kept buyers, used as the lookalike seed and exclusion list. Refunded buyers are left out.

### 7.3 Never send

- **Psychiatric-service-dog or service-dog information:** interest (quiz step 14), PSD page visits, PSD products or scripts.
- **Veteran or military status**, which the call script asks for [I3].
- **Health information about people.**
- **Pet-insurance answers.**
- **Free-text quiz notes (step 16), call notes, recordings, transcripts.**
- **Card data.**
- **Unhashed contact data in CAPI.**

Meta prohibits health information, and "the names you choose and criteria you establish for your events, conversions, and custom audiences must not reflect, imply, or be based on any prohibited information" [H25]. Since 2025-09-02 Meta flags custom conversions that suggest health conditions and blocks them from new campaigns [D21].

How the rule is enforced:
- `MetaPayloadGuard` allowlists keys and rejects forbidden terms in event names, parameters and URLs.
- PSD and service-dog funnels are excluded from both the pixel and CAPI.

### 7.4 Accuracy and timing

- `action_source` must be accurate [D1]. Never relabel.
- Only one server sender (§1).
- Send in real time, under 1 hour [D10]. Nothing older than 7 days will be accepted [D1].
- Keep event names stable. Custom event names are at most 50 characters [D21] and in English CamelCase for offline events [H12].

### 7.5 Consent

- **Today:** there is no consent platform. All tags fire on load [I1].
- **The mechanism to build:** a `MetaConsent` service in which a visitor is `optedOut` if either:
  - the request has `Sec-GPC: 1`, or
  - the visitor used a "Do not sell or share my personal information" preference (cookie `da_dns=1`), which also sets `lead_meta_identities.meta_opt_out`.
- **Two policies, chosen by DA's counsel:**
  - **`optout_suppress`** (default, simplest): opted-out visitors get no pixel, no cookies and no CAPI.
  - **`optout_ldu`:** send with Limited Data Use.
    - Pixel: `fbq('dataProcessingOptions', ['LDU'], 0, 0)` before `init`.
    - CAPI: `data_processing_options=["LDU"]`, `data_processing_options_country=0`, `data_processing_options_state=0`. Meta then geolocates the event.
    - Meta lists California, Colorado, Connecticut, Delaware, Florida, Montana, Nebraska, New Hampshire, New Jersey, Oregon, Texas, Minnesota, Maryland and Rhode Island as covered states [D24].
- **Separate from Meta:** the TCPA call and SMS consent in the quiz (`sms_opt_in`, `auto_dial_opt_in`) is a different thing and is not handled here.

---

## 8. Meta-side setup (DA admin with AdMaker; the order matters)

1. **Access (blocker).** Identify the Business that owns pixel `481578137302100`, the DA Page and the ad account, and give the operator admin access. Our connector currently gets `Invalid dataset_id` [I7]. Then reconnect the Meta connector.
2. **Dataset health.** In Events Manager, open `481578137302100` and check:
   - **Settings → data source category.** There must be no restricted category [H27].
   - **Diagnostics.** There must be no "prohibited information" notices [H25].
   - **First-party cookies** on, and automatic advanced matching on (already true [I1]).
   - **Consolidation.** `GET /v25.0/481578137302100?fields=is_consolidated_container` must be `true`, which is what allows offline events on this dataset [D12].
3. **One sender.** Under **Manage integrations**, confirm there is no Meta-enabled CAPI, Gateway or partner integration [H23].
4. **System user and token.**
   - Go to Business Settings → Users → System users → Add.
   - Assign the dataset with **Manage pixel** permission [D18].
   - Generate the token. Alternatively use Events Manager → Datasets → `481578137302100` → **Manage integrations → Conversions API → Complete setup → Generate access token** [H22].
   - Store it in AWS Secrets Manager or SSM as `META_CAPI_ACCESS_TOKEN`. Use a **separate** token and dataset for staging. Record who holds it, and rotate it when staff change.
5. **Staging dataset.** Create `DA Staging` (`<STAGING_DATASET_ID>`). Staging must never post to the production dataset, because test events are "not dropped" [D7].
6. **Domain.** Verify `dogacademy.org` by DNS TXT (§5.3) [H24].
7. **Custom conversions.** Create these **before paid traffic starts**, because offline custom conversions only count data received after they are created [H14]. Each source event must already have reached dataset `481578137302100` at least once (for example from the labelled test lead, QA-RUNBOOK §8) before Events Manager will offer it. The limit is 100 per ad account [H18].
   - **"Schedule – in service area":** data source `481578137302100`, event `Schedule`, rule on the parameter `in_service_area` equals `yes`. Web; it can later be a performance goal [H18].
   - **"Consultation completed":** event `ConsultationCompleted`, offline, measurement.
   - **"TFL purchase"** (optional): event `Purchase`, rule `content_name` equals `trained_for_life`. Measurement only; it separates TFL from course purchases. It can only be created once the first real TFL sale has arrived, because production `Purchase` is never faked. The standard `Purchase` event reports sales in the meantime.
   - Names are neutral. There must be no health or PSD terms [H25][D21].
8. **Review custom events.** If Events Manager asks you to review custom events (`QuizStart`, `PartialLead`, `ConsultationCompleted`, `SaleKept`), confirm them [H26][H12].
9. **Test Events.** Copy the Test Events code for staging and for the labelled test lead [H21].
10. **Gate 0 (§0.4).** Then configure the ad set: Leads → Website → dataset `481578137302100` → Schedule → 7-day click. The account-plan thread owns the build.

---

## 9. QA

See [`QA-RUNBOOK.md`](QA-RUNBOOK.md). It covers:
- Test Events checks for each event;
- the deduplication check;
- EMQ targets;
- stage coverage;
- the 7-day reconciliation SQL plus the Meta stats API call;
- the end-to-end labelled test lead;
- the go/no-go gate.

**The launch gate:** G0–G9 must all be green. G10 (the day-7 reconciliation) must be green before spend is scaled.

---

## 10. Rollout plan

| Step | What | Owner | AdMaker verifies |
|---|---|---|---|
| R0 | Meta access to the Business, dataset, ad account and Page; reconnect the connector | Operator + DA admin | Readback shows dataset details, stats and quality are readable |
| R1 | Gate 0-A/B in the UI (drafts only) | AdMaker | Dated screenshots; crm mode yes or no |
| R2 | Data pack: stage definitions (§6.3), CRM/dialer/billing vendors and webhook ability, lag distribution (share within 7 and 28 days), monthly stage counts, service-area rule | Matthew | Stage table signed off |
| R3 | Staging dataset, token and env vars | DA admin + DA dev | Staging Test Events receives events |
| R4 | Build web: pixel partial on every layout, PSD exclusion, domain tag, tk-fb v2, middleware and Parameter Builder, identity capture, outbox and job, `Lead` + `Schedule` | DA dev | QA T1–T9 on staging |
| R5 | Build the stage feed: webhook, ingestor, CSV command. DA ops connect the CRM, dialer and billing (or Zapier/Make) | DA dev + DA ops | QA T10–T18 on staging |
| R6 | Production deploy behind flags: `META_CAPI_ENABLED=true` for web events first, then the stage feed | DA dev | QA T1–T9 on production; 48 h of EMQ, coverage and freshness |
| R7 | The labelled test lead in production, coordinated so sales doesn't work it | AdMaker + Matthew | QA §8 checklist |
| R8 | Go/no-go | Operator approves | G0–G9 green |
| R9 | Launch: Leads → Website → Schedule, 7-day click | Account-plan thread | Ad-set readback |
| R10 | Reconcile on day 7, then weekly | AdMaker + DA dev | QA §9 tolerances |
| R11 | Qualified-leads challenger (crm mode) if Gate 0-A passed | AdMaker + DA dev | Events Manager CRM status; cohort cost per consultation and per sale |

**Who does what:**
- **DA's developer builds:** everything in §3–§6 and the tests in §4.11.
- **DA admin:** Business settings, token, domain and dataset category.
- **Matthew and DA ops:** stage definitions and the feeds.
- **AdMaker** writes nothing into DA's systems. It:
  - runs Gate 0;
  - checks Test Events, deduplication and payloads against `event-contract.json`;
  - monitors EMQ, coverage and freshness (Dataset Quality API);
  - runs the reconciliation;
  - makes the go/no-go call and signs off.

---

## Appendix A: open questions

1. **For Matthew:** the vendors and webhook ability for the CRM, dialer and billing systems; the stage definitions (§6.3); consultation channel mix (phone or video); where payment is taken (phone, virtual terminal or link); lag distributions; how the service area is defined.
2. **For DA's developer:** Laravel and PHP versions; queue driver; lead and booking table names; whether the lead ID reaches the sale record; whether `__ussa` can be extended; which legacy `send-fb-event` names exist.
3. **For the DA admin:** which Business owns the pixel and the domain verification; any existing CAPI or Gateway integration.

## Appendix B: UNVERIFIED items (to be closed by Gate 0 or Test Events)

- U1: whether `phone_call`, `other` or `system_generated` events, or custom conversions built on them, can be the optimization event of a Website ad set. The docs imply they can't (§0.2).
- U2: whether "Maximize number of qualified leads" works for website forms in DA's account, and what signal and window it uses (§0.4).
- U3: whether the pixel hashes `external_id` automatically (§3.4).
- U4: whether Meta merges server `user_data` into a browser event it keeps after deduplication.
- U5: whether the 28-day-click comparison in Ads Reporting includes `phone_call` events.
- U6: which Graph error codes are safe to retry (§4.6).
- U7: whether the current domain-verification tag belongs to DA's Business (§5.3).
- U8: whether the dataset is consolidated for offline events (§8.2).
- U9: whether Livewire v2 fires dispatched browser events before a redirect in the same response. The session queue avoids depending on it (§4.5).
