# Dog Academy: Meta Conversions API, developer handoff

For Dog Academy's developer, 29 September 2026. Prepared by AdMaker for Brett and Matt. Nothing has been installed or
changed yet.

**You decide how to build it.** This document says what to build, where we'd put each piece in your app, and how we'll
know it works. We wrote it from what the live site shows from outside: a Laravel app with Livewire, behind an AWS load
balancer, with DNS on Cloudflare. Correct anything we got wrong.

Reference code for every piece is in the [full build spec](full-spec.html): the migration, service, job, middleware and
tests. Event rules in machine-readable form are in [`event-contract.json`](event-contract.json).

## 1. What you're building

1. **Three website events, sent twice.** Lead (the quiz is submitted), Schedule (the first free consultation is
   booked) and **Purchase (Trained for Life paid on dogacademy.org)**. Both the browser pixel and your server send each
   one to Meta dataset `481578137302100`, with the same event ID, so Meta keeps one copy.
2. **Later stages from your own systems.** Your CRM, dialer or billing tells your site when a consultation is held or a
   sale closes. It does this through one small signed endpoint. Your site forwards the stage to Meta. Nothing else talks
   to Meta.
3. **Ads optimize to the sale: Purchase.** Meta can only optimize to a sale that happens on your website, within 7 days
   of the ad click. So the rep closes on the call and **sends a pay link**, and the customer pays on dogacademy.org.
   That checkout fires Purchase. Sales paid by card over the phone still come in through the stage feed, but only for
   reporting.

**Three hard rules:**
- **Tracking must never block the quiz or the booking.** Every Meta call is queued and wrapped in try/catch.
- **Ship it behind `META_CAPI_ENABLED`**, off by default, and turn it on per environment after the tests.
- **Never send health or service-dog information** (section 5).

## 2. How it works

```
Visitor's browser (dogacademy.org)
  │  pixel on every page; Lead + Schedule sent with eventID ───────────────┐
  │  quiz answers + _fbc/_fbp cookies                                       ▼
  ▼                                                          Meta dataset 481578137302100
Laravel app (your server)                                    (keeps one copy per event ID;
  │  saves click ID + hashed contact on the lead              optimizes to Schedule)
  │  sends the same Lead + Schedule through CAPI ─────────────────────────────▲
  ▲                                                                           │
  │  POST /api/meta/stage-events (signed, lead ID)                            │
CRM / dialer / billing ── consultation held, sale ──▶ app sends ConsultationCompleted / Purchase
```

1. **Ad click.** The visitor arrives with `fbclid` in the URL. Middleware turns it into the `_fbc` cookie and keeps
   `_fbp`.
2. **Email step.** When the quiz saves the lead, your app stores the click IDs, IP, browser and first-touch ad IDs
   against that lead.
3. **Quiz submitted.** The browser fires `Lead` with `eventID = da:lead:{lead_id}`. The server queues the same event
   with the same ID.
4. **Consultation booked.** The same pattern, with `Schedule` and `da:schedule:{lead_id}`. First booking only.
5. **Paid on the site.** The rep sends the pay link on the call. When the customer pays, the confirmation page fires
   `Purchase` with `eventID = da:purchase:{order_id}`, plus the value and currency. The server sends the same event,
   matched to the lead's saved identity. **This is the event the ads optimize to.**
6. **Later stages.** The CRM, dialer or billing calls `POST /api/meta/stage-events`. Your app looks up the lead's saved
   identity and sends `ConsultationCompleted` or a phone-paid `Purchase` to Meta, labelled honestly as a phone call or
   other.

## 3. What to build, and where we'd put it

| # | What | Where (our suggestion) | Done when |
|---:|---|---|---|
| 1 | Meta access, system-user token, staging dataset | Meta Business Settings, done by a DA admin. Give the system user "Manage pixel" on dataset `481578137302100`. Create a second dataset, "DA Staging", with its own token. | Brett can see the dataset. Both tokens are stored in your secret store, never in code, tickets or email. |
| 2 | Domain verification | A Cloudflare DNS TXT record for `dogacademy.org`, taken from Business Settings → Brand safety → Domains | Meta shows the domain as Verified. |
| 3 | One server sender | Events Manager → dataset → Settings → Manage integrations | Nothing else sends server events: no "Meta-enabled CAPI", no Gateway, no partner app. |
| 4 | Config | `.env` + `config/meta.php`: `META_CAPI_ENABLED`, `META_PIXEL_ID=481578137302100`, `META_CAPI_ACCESS_TOKEN`, `META_GRAPH_API_VERSION=v25.0`, `META_TEST_EVENT_CODE` (staging only), `META_STAGE_WEBHOOK_SECRET`, `META_LDU_MODE`. Secrets are injected from AWS Secrets Manager or SSM. | The token never appears in the repo or in logs. |
| 5 | Pixel on every page | One partial, `resources/views/partials/meta-head.blade.php`, included in the `<head>` of every Blade layout: standard, booking, program pages (private, group, online), city pages, the scheduler, register and login | Each page loads the pixel exactly once, with one `fbq('init')`. |
| 6 | No pixel on health funnels | Layouts or routes for `/pages/*psychiatric*` and `/pages/*service-dog*` | No pixel and no server events on those pages. Today `/pages/service-dog-training-consult` fires the pixel. |
| 7 | Browser events with event IDs | Replace `public/assets/js/tk-fb.js` (v2 is in the full spec §5.4). Livewire dispatches `send-fb-event` with `{name, params, eventId}`. | Every non-PageView event carries an `eventID` that matches the server event. The old name-only dispatches are deleted, so Lead isn't counted twice. |
| 8 | Correct matching | The pixel's advanced matching, on the lead's pages | The owner's own name, email, phone and ZIP are passed. The dog's-name field is never read as the owner's name. |
| 9 | Click capture | `app/Http/Middleware/CaptureMetaClickIds.php` in the `web` group, using `composer require facebook/capi-param-builder-php` (1.3.2) | With `?fbclid=`, `_fbc` is set if missing (90 days, `.dogacademy.org`) and `_fbp` is kept. The first-touch UTMs plus `campaign_id`, `adset_id` and `ad_id` are saved in the session. |
| 10 | Laravel gotchas | `EncryptCookies::$except` gets `_fbc`, `_fbp` and `_fbi`. `TrustProxies` is set for the AWS load balancer. | `$request->ip()` returns the visitor's IP, not the load balancer's. |
| 11 | Three tables | One migration (full spec §3.5): `lead_meta_identities` (click IDs, IP, browser, ad IDs per lead), `meta_capi_events` (outbox, unique `event_id`), `lead_stage_events` (the inbound stage log) | The migration runs on staging. Call notes, recordings and card data are never stored in these tables or in logs. |
| 12 | Server sender | `app/Services/Meta/CapiService.php` + `app/Jobs/SendMetaEvent.php` on a `meta-capi` queue. `POST https://graph.facebook.com/v25.0/481578137302100/events`, form-encoded, one event per request. | Retries with backoff. Failures alert. Nothing older than 7 days is sent. Most events arrive within an hour. A Meta outage never affects the quiz. |
| 13 | Lead hook | The quiz component `frontend.training.book-training`: save the identity row at the email step and fire `Lead` in the final-submit (`loading:submit`) handler | One `Lead` per lead, `da:lead:{lead_id}`, from browser and server. |
| 14 | Schedule hook | Where the lead's first booking is created on `/custom-training-program/schedule-consultation` | One `Schedule` per lead, `da:schedule:{lead_id}`, for reporting. Reschedules send nothing. |
| 15 | **Pay link + Purchase (the optimization event)** | A signed pay-link route per order on dogacademy.org (for example `/pay/{order}`), using your existing checkout and processor. Reuse the course checkout if you can. The order confirmation page fires the event. | A paid order fires `Purchase` from the browser and the server: `da:purchase:{order_id}`, the real `value`, `currency: USD`, matched to the lead. Reps can send the link by SMS or email during the call. |
| 16 | Booking link that survives in-app browsers | The post-quiz redirect: a signed URL valid for 48 hours, instead of a session that bounces to `/login` | A lead opening the booking page inside the Facebook or Instagram app can book without logging in. |
| 17 | Stage endpoint | `routes/api.php`: `POST /api/meta/stage-events` + `VerifyStageSignature` (HMAC-SHA256 over `timestamp.body`, headers `X-DA-Timestamp` and `X-DA-Signature`) + a `StageEventIngestor` (full spec §6) | A signed test call returns `202 queued`. A replay returns `duplicate`. Negative stages are stored, not sent. |
| 18 | Connect your systems | Preferred: your CRM, dialer and billing call the endpoint on status changes. Fallbacks: a daily CSV or Sheet import (`php artisan meta:import-stages`), or Zapier or Make posting to the same endpoint. | A consultation marked held and a test sale show up in Meta within an hour. No tool posts to Meta directly. |
| 19 | Heyflow city funnels | Heyflow's own settings (`training.dogacademy.org`) | Your call: add the same pixel and events, or keep Meta ads off them. |
| 20 | Privacy mode | `META_LDU_MODE` + Global Privacy Control handling (full spec §7.5) | Counsel picks a mode: either opted-out visitors get no tracking, or they are sent with Meta's Limited Data Use flag. |

## 4. Event contract

| Event | Fires when | Sent by | `action_source` | `event_id` | `custom_data` |
|---|---|---|---|---|---|
| Lead | Final quiz submit succeeds | Browser + server | `website` | `da:lead:{lead_id}` | `content_name: book_training_quiz`, `content_category`, `in_service_area` |
| Schedule | The lead's first booking is created on the site | Browser + server | `website` | `da:schedule:{lead_id}` | `content_name: free_consultation`, `content_category`, `in_service_area` |
| Schedule (agent) | Stage feed `consultation_scheduled`, if there is no website Schedule | Server | `phone_call` | `da:schedule:{lead_id}` | the same |
| ConsultationCompleted | Stage feed `consultation_completed` (first per lead) | Server | `phone_call`; `other` for video | `da:consult:{lead_id}` | none |
| **Purchase** ★ | The customer pays through the pay link on dogacademy.org. Phone-card sales arrive through the stage feed instead. | Browser + server (site checkout); server only for phone sales | `website` (site checkout); `phone_call` or `other` (phone) | `da:purchase:{order_id}` | `value`, `currency: USD`, `order_id`, `content_name: trained_for_life` |
| SaleKept (optional) | A daily job, 30 days after a purchase with no refund | Server | `system_generated` | `da:salekept:{order_id}` | `value`, `currency`, `order_id` |

★ **Purchase paid on dogacademy.org is the event the ads optimize to** (Sales objective → Website → Purchase). Lead and Schedule are for reporting and diagnosis.

**User data:**
- **Website events:** hashed `em`, `ph`, `fn`, `ln`, `ct`, `st`, `zp`, `country` and `external_id`, plus raw
  `client_ip_address`, `client_user_agent`, `fbc` and `fbp`.
- **Stage-feed events:** the same hashed keys plus `fbc`/`fbp` saved on the lead. They carry no IP or browser, because a
  phone call has none.

**Hashing:** SHA-256, lowercase hex, exactly once.
- Email: trimmed and lowercased.
- Phone: digits with the country code (`16505551212`).
- ZIP: the first 5 digits.
- City: lowercase, no spaces.
- State: 2 letters.
- Country: `us`.
- `external_id`: `da-lead-{lead_id}`.

The `value` sent with a purchase is used only inside Meta reporting. Ads never show a price.

## 5. Never sent to Meta

- Service-dog, psychiatric-service-dog or emotional-support information: quiz answers, page visits, products.
- Veteran or military status.
- Any health information, and pet-insurance or vet-visit answers.
- Free-text quiz notes, call notes, recordings.
- Card data.
- Unhashed contact details.
- Negative outcomes: refunds, no-shows, cancellations and lost deals are stored, but never sent.

## 6. Examples (placeholders in angle brackets)

**Browser:**

```js
fbq('track', 'Schedule', {content_name: 'free_consultation'}, {eventID: 'da:schedule:<LEAD_ID>'});
// on the pay-link confirmation page (the optimization event):
fbq('track', 'Purchase', {value: <AMOUNT>, currency: 'USD', content_name: 'trained_for_life'}, {eventID: 'da:purchase:<ORDER_ID>'});
```

**Server, the same event** (the `data` array in the form body; `access_token` in the form body, never in the URL):

```json
[{
  "event_name": "Schedule",
  "event_time": 1759167900,
  "event_id": "da:schedule:<LEAD_ID>",
  "action_source": "website",
  "event_source_url": "https://dogacademy.org/custom-training-program/schedule-consultation",
  "user_data": {
    "em": ["<SHA256>"], "ph": ["<SHA256>"], "fn": ["<SHA256>"], "ln": ["<SHA256>"],
    "ct": ["<SHA256>"], "st": ["<SHA256>"], "zp": ["<SHA256>"], "country": ["<SHA256>"],
    "external_id": ["<SHA256 of da-lead-LEAD_ID>"],
    "client_ip_address": "<VISITOR_IP>", "client_user_agent": "<VISITOR_UA>",
    "fbc": "<_fbc cookie>", "fbp": "<_fbp cookie>"
  },
  "custom_data": {"content_name": "free_consultation", "in_service_area": "yes"}
}]
```

**Stage feed:** your CRM, dialer or billing calls your site, signed with `X-DA-Timestamp` and
`X-DA-Signature: sha256=<hmac>`.

```json
{
  "source": "dialer",
  "event_id": "<SOURCE_UNIQUE_EVENT_ID>",
  "stage": "purchase",
  "occurred_at": "2026-10-02T17:31:05Z",
  "lead": {"da_lead_id": "<LEAD_ID>"},
  "order": {"order_id": "<ORDER_ID>", "amount": <AMOUNT>, "currency": "USD",
            "product_line": "tfl_in_person", "payment_channel": "phone"},
  "test": false
}
```

**Field rules:**
- `stage` is one of: `consultation_scheduled`, `consultation_completed`, `purchase`, `refund`, `cancelled`, `no_show`,
  `disqualified`.
- `occurred_at` is the real moment of the change, not the upload time.

**Responses:**
- `202 queued`
- `200 duplicate`
- `200 stored_not_sent` (already sent, negative stage, too old, excluded, or unmatched)
- `401` for a bad signature
- `422` for invalid input

## 7. How we'll know it works (all must pass before ads spend)

- [ ] **Staging:** every event appears in Events Manager → Test Events, and the browser and server copies of Lead and
      Schedule merge (90%+ deduplicated).
- [ ] **Payloads:** they contain nothing from section 5. Contact details are hashed only. IDs follow the patterns in
      section 4.
- [ ] **Click ID:** `_fbc` is present on at least 90% of leads that came from a Meta ad.
- [ ] **Pay link:** a test payment (processor test mode on staging) fires `Purchase` from the browser and the server,
      deduplicated, with the right value, and matched to the test lead.
- [ ] **Stage feed:** a consultation marked held and a phone-paid test sale reach Meta within an hour. A replay is
      rejected as a duplicate.
- [ ] **Resilience:** turning off network access to Meta does not break the quiz or the booking page.
- [ ] **One end-to-end test lead,** on a Dog Academy phone number, because the quiz rejects fictional numbers: quiz,
      booking, held, then paid through the pay link. Each step is visible in Events Manager.
- [ ] **Ads Manager** offers Purchase on dataset `481578137302100` under Sales → Website.
- [ ] **48 hours after go-live:** Event Match Quality of at least 7.5 on Lead and Schedule (target 8), and events arriving
      within an hour.

We check all of this with you in Events Manager. We never write into your systems.

## 8. Decisions for your team

- **Your CRM, dialer and billing tools:** can each call a webhook? If not, use the daily file or Zapier.
- **What counts as "consultation completed"** (disposition, minimum talk time, phone or video), and **what counts as a
  sale** (first payment or signed contract, and which amount).
- **The pay link:** which checkout and processor it uses (reuse the course checkout if you can), and whether reps send
  it by SMS, email or both. This is required, because it's what lets the ads optimize to sales.
- **Heyflow city funnels:** pixel them, or keep them off Meta.
- **Privacy mode:** counsel's choice (item 20).

Questions go to Brett. Reference code: the [full build spec](full-spec.html). The business overview for Matt is
[here](https://vps-327b7201.vps.ovh.us/admaker/dog-academy-capi-for-matt/).
