For Dog Academy's developer. Written 2026-09-28 from the live site. Nothing has been sent to Meta. Tokens are placeholders; the pixel ID is public.
The companion event contract is event-contract.json in the AdMaker repo.
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:
481578137302100is public, so it appears as-is. - Companion files, all in this folder:
event-contract.jsonis the canonical contract. Where the two disagree, the JSON wins.QA-RUNBOOK.mdhas the tests and the go/no-go gate.sources.mdresolves 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
scheduleas 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:
- A custom conversion, "Schedule – in service area". This is the website
Schedulefiltered on the parameterin_service_area = yes. Switch to it once it has enough volume [H18]. - "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.
- 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 toConsultationCompletedonce 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,
Purchaseonly,physical_storeevents [H15][D12]; - CRM conversion leads:
system_generatedevents [D13–D16].
- the "Website and in-store" location: Sales objective,
- 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_callevent. - Custom conversions can be tied to other sources, but that proves nothing about optimization. The custom-conversion API takes an
action_source_typethat includesphone_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).
Leadand a self-bookedSchedulehappen minutes after the click, so they are fully inside the window.ConsultationCompletedwould 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_idandad_idon 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_typewith valuesoffsite_crmandoffsite_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?
- In Ads Manager, click + Create → Leads → Continue. Note whether the Advantage+ leads set-up or the manual set-up opens.
- At the ad set, go to Conversion and set Conversion location = Website.
- 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.
- 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.
- which dataset(s) it accepts (does it take
- Repeat steps 2–4 for Website and calls and Website and instant forms. Record only; do not use them.
- 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. - Still in Events Manager, click + Connect data → CRM. Does it let you choose the existing dataset
481578137302100? Stop before confirming. - 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
Scheduleis listed. - Once the stage feed is live on the production dataset (rollout step R6), record whether
ConsultationCompleted,Purchase, or a custom conversion built onphone_call/otherevents 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 carryevent_source_urlon dogacademy.org, which is required for website events [D1][D2]. - Offline keys: the same list without
client_ip_addressandclient_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()andgetNormalizedAndHashedPII()[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, onlydb + client_user_agent, onlyfn + geor onlyln + 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_idspecifically 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
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
payloadholds only hashed identifiers plus IP and user agent. It is nulled after 30 days bymeta: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):
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
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
-
Cookie encryption. Laravel's
EncryptCookiesdecrypts every incoming cookie and encrypts every outgoing one. The pixel's plain_fbp/_fbcwould then read as empty, and cookies set by the server would reach the pixel encrypted. Exclude them:// app/Http/Middleware/EncryptCookies.php protected $except = ['_fbc', '_fbp', '_fbi']; -
Proxies. The app sits behind an AWS ELB and nginx [I1]. Set
App\Http\Middleware\TrustProxies::$proxiesto 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, readCF-Connecting-IP.
- Cloudflare only proxies
4.3 Middleware: capture the click ID on every page
Register it at the end of the web middleware group, inside StartSession.
<?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
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
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
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
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
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_idis deterministic andUNIQUEin 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
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
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) and200/10(permissions) alert right away. - Code
100(invalid parameter) goes straight todeadwith 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
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:flushruns every 5 minutes. It re-dispatches rows that arependingorfailed, were last updated more than 2 minutes ago, and haveevent_timewithin 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:pruneruns daily and setspayload = NULLon rows older than 30 days. The metadata is kept.meta:capi:reconcileruns daily and prints the 7-day tables fromQA-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):
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_identitiesfor the lead from:- the session (
meta.fbclid,meta.fbclid_first_seen_ms,meta.fbc,meta.fbp,meta.utm,meta.landing_path); - the
__ussacookie fields; - the raw
_fbc/_fbpcookies; $request->ip()and$request->userAgent().
- the session (
event_source_urlis the page the visitor was on, taken from theRefererof the Livewire request. Keep only the path, only if the host isdogacademy.org, and fall back tohttps://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.
- Gotcha: inside a Livewire request,
is_meta_clickis true when there is an fbclid or_fbc, or whenutm_sourceis one offacebook|fb|instagram|ig|meta.meta_excludedis true for leads from the PSD or service-dog funnels (§7.3).meta_opt_outfollows §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
/loginwhen the session is missing [I2]. Give the post-quiz redirect a signed URL, for exampleURL::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
PurchasewitheventID = 'da:purchase:{order_id}',valueandcurrency. - The server records the same event with
action_source=website[D22]. - Course purchases (existing checkout) may send
Purchasewithcontent_category=course. TFL always usescontent_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}), orfbq('trackCustom', …)for custom events [D9]. - The rule. The same
event_nameand 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_dataas the server event. If the browser event is the one kept, thein_service_areaparameter must still be there for the custom-conversion rule. - Don't dedup on
external_id/fbpalone. 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_idandevents_received. - Never log the token, the request body, raw PII or hashed PII.
- Scrub
access_tokenin the exception reporter (for example Sentrybefore_send).
Alerts (MetaAlerts, posting to the Slack webhook plus email):
- Immediately: any
deadevent; any error190,200or10. - Hourly: failed or dead above 2% of attempts.
- During business hours: zero
Leadsends 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_tokenand no@characters instorage/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.
<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, countryandexternal_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 beforeinit[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-consultfires 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
Scheduleoptimization. - 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
// /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].
_fbcis set server-side when it is missing and lasts 90 days on.dogacademy.org[D4].- Nothing depends on the Livewire
urlproperty 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
Leadevents from DA's existing/api/v1/heyflow/submithandler, withevent_source_urlontraining.dogacademy.organdfbcbuilt 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=instagramautomatically [H29], so DA's number-swap logic andis_meta_clickshould also acceptinstagram|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.
{
"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:
stageis one of:consultation_scheduled,consultation_completed,purchase,refund,cancelled,no_show,disqualified.orderis required whenstage=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_atis 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"}401for a bad signature422for 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
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)
-
Validate. Insert into
lead_stage_eventswith a unique(source, event_id). If it is a duplicate, returnduplicate. -
Resolve the lead. Match on
da_lead_id. Failing that, match the normalized email or phone to the most recent lead created beforeoccurred_atwithin 60 days. If nothing matches, the result isunmatchedand an alert goes out. -
Excluded and opted-out leads are stored with result
excludedand never sent. -
Negative stages (
refund,cancelled,no_show,disqualified) are stored with resultignored_negativeand never sent to Meta (§7.1). -
Too old. If
occurred_atis more than 6 days 23 hours ago, the result istoo_oldand nothing is sent [D1][D7]. -
Map to Meta events in offline mode, the default:
Stage Meta event action_sourceevent_idNotes consultation_scheduledSchedulephone_callda:schedule:{lead_id}If the website Schedulealready exists for the lead: resultalready_sent, nothing is sentconsultation_completedConsultationCompletedphone_callifchannel=phone, otherwiseotherda:consult:{lead_id}First completion per lead only purchasePurchasephone_callifpayment_channel=phone;otherforpayment_link/otherda:purchase:{order_id}If payment_channel=website, the site checkout already emitted it: resultalready_sent. A webhook can never send awebsiteevent, because it has no browser user agent or URL [D2] -
Backfill. If a
purchasearrives before any consultation event for the lead, first emitConsultationCompleted. 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]. -
Suppress PSD sales. A
purchasewithproduct_lineindicating 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
sha256HMAC 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_generatedcustom_data: {"event_source":"crm","lead_event_source":"<DA_CRM_NAME>"}user_data: the CRM keys (§2). There is nolead_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 Completedreplaces the offlineConsultationCompleted.Purchase(standard) is still sent for audiences and value. - Reporting. Never add
Sale ClosedandPurchasetogether.
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_eventsand stops there.
7.2 Refunds: the "sale kept" option
Refunds are the most common in-person complaint [I5]. Three choices:
- Do nothing.
Purchasestays as sent. This is the simplest option. - Optional
SaleKept. A custom event withaction_source=system_generated, sent at day 30 when no refund was recorded, withevent_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.
- 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:
MetaPayloadGuardallowlists 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_sourcemust 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
MetaConsentservice in which a visitor isoptedOutif 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 setslead_meta_identities.meta_opt_out.
- the request has
- 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)beforeinit. - 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].
- Pixel:
- 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)
- 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 getsInvalid dataset_id[I7]. Then reconnect the Meta connector. - Dataset health. In Events Manager, open
481578137302100and 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_containermust betrue, which is what allows offline events on this dataset [D12].
- One sender. Under Manage integrations, confirm there is no Meta-enabled CAPI, Gateway or partner integration [H23].
- 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.
- Staging dataset. Create
DA Staging(<STAGING_DATASET_ID>). Staging must never post to the production dataset, because test events are "not dropped" [D7]. - Domain. Verify
dogacademy.orgby DNS TXT (§5.3) [H24]. - 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
481578137302100at 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, eventSchedule, rule on the parameterin_service_areaequalsyes. Web; it can later be a performance goal [H18]. - "Consultation completed": event
ConsultationCompleted, offline, measurement. - "TFL purchase" (optional): event
Purchase, rulecontent_nameequalstrained_for_life. Measurement only; it separates TFL from course purchases. It can only be created once the first real TFL sale has arrived, because productionPurchaseis never faked. The standardPurchaseevent reports sales in the meantime. - Names are neutral. There must be no health or PSD terms [H25][D21].
- "Schedule – in service area": data source
- Review custom events. If Events Manager asks you to review custom events (
QuizStart,PartialLead,ConsultationCompleted,SaleKept), confirm them [H26][H12]. - Test Events. Copy the Test Events code for staging and for the labelled test lead [H21].
- 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. 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
- 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.
- For DA's developer: Laravel and PHP versions; queue driver; lead and booking table names; whether the lead ID reaches the sale record; whether
__ussacan be extended; which legacysend-fb-eventnames exist. - 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,otherorsystem_generatedevents, 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_idautomatically (§3.4). - U4: whether Meta merges server
user_datainto a browser event it keeps after deduplication. - U5: whether the 28-day-click comparison in Ads Reporting includes
phone_callevents. - 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).
Dog Academy CAPI: QA runbook and go/no-go gate
Version 1.0, 2026-09-28.
- Companion to
DEV-SPEC.mdandevent-contract.json. The contract is canonical. - Citations resolve in
sources.md. - Placeholders are written
<LIKE_THIS>. - Evidence (screenshots,
event_ids,fbtrace_ids) goes in a dated QA folder underbrands/dog-academy/research/, never in this spec folder. - Tokens never appear in evidence, screenshots, tickets or chat.
0. Roles, environments, test identity
| Item | Rule |
|---|---|
| Staging | Its own dataset, <STAGING_DATASET_ID>, and its own token. META_TEST_EVENT_CODE is set. Staging never posts to 481578137302100, because "Events sent with test_event_code are not dropped. They flow into Events Manager and are used for targeting and ads measurement purposes" [D7]. |
| Production | Dataset 481578137302100. A test code applies only to the labelled test lead, through META_TEST_LEAD_EMAIL_REGEX + META_TEST_EVENT_CODE_PROD. |
| Test identity | A consenting team member whose email and phone belong to a Meta account. Meta says test events "may get discarded if they don't match a Facebook or Meta account" [D8]. Name Capi Test, email capi-test+<YYYYMMDD>@<TEAM_DOMAIN>, a zip in a served area, dog Testdog. |
| Who runs what | DA's developer runs the staging tests. AdMaker witnesses them and checks every payload against the contract. Matthew stops sales from working the production test lead. |
| Test Events | Browser activity "is only recorded while the test events web page is open", so open the site from Test Events [H21]. Test data stays for 24 hours [H21]. |
1. Gate 0: run in the Ads Manager UI before building crm mode
Follow DEV-SPEC §0.4 (0-A, 0-B, optional 0-C). Save drafts only and never publish. Record the results in this table:
| Check | Result (fill in) | Screenshot |
|---|---|---|
| 0-A: "Maximize number of qualified leads" is listed for Leads → Website and can be selected | yes / no / greyed out (paste tooltip) | |
0-A: dataset(s) it accepts (does it take 481578137302100?) |
||
| 0-A: signal-source options shown | ||
| 0-A: stage or target picker, and the events it lists | ||
| 0-A: requirement or eligibility messages; any stated window | ||
| 0-A: the same checks for Website and calls, and for Website and instant forms (recorded only) | ||
| 0-A: the dataset Settings page shows a CRM integration or "Configure sales funnel"; the data source category | ||
0-B: Schedule can be selected as the conversion event under Leads → Website |
must be yes to launch | |
| 0-B: non-website events or their custom conversions appear in that list (record only; don't use them) |
Decision:
- 0-A positive → crm mode becomes a challenger cell (DEV-SPEC §6.6).
- 0-A negative → stay on
Schedule. - 0-B negative → stop and investigate the dataset and ad-account connection.
2. Static, cookie and pixel-coverage checks (staging, then production)
| ID | Test | Pass condition |
|---|---|---|
| T1 | Load /, /in-person-dog-training/book-training, the private-, group- and online-training pages, one city page, /register, /login and the scheduler page (signed in). Watch Test Events → browser |
Exactly one PageView per load, from pixel 481578137302100. No page initializes the pixel twice. |
| T2 | Load /pages/psychiatric-service-dog-training-consult, /pages/service-dog-training-consult, /blog/, /adoption/ |
No request to facebook.com/tr and no fbq in the page source |
| T3 | Run the domain-tag check below, then open Business Settings → Brand safety → Domains | The tag is present in static HTML; dogacademy.org shows Verified in DA's Business [H24] |
| T4 | Open https://dogacademy.org/in-person-dog-training/private-training?fbclid=CAPITEST<YYYYMMDD>&utm_source=facebook in a clean browser |
The response sets _fbc (Set-Cookie, domain dogacademy.org, path /, not HttpOnly) and the value contains CAPITEST<YYYYMMDD>. _fbp exists. The first-touch UTMs are in the session or __ussa. |
| T5 | Continue to the quiz and reach the email step | The lead_meta_identities row stores plain fb.1.… fbc and fbp values (not encrypted blobs), IP, user agent, the quiz page path and UTMs |
The T3 domain-tag check (the tag must be in the static HTML, because Meta's crawler does not run JavaScript [H24]):
curl -s https://dogacademy.org/ | grep -o 'facebook-domain-verification[^>]*'
3. Event checks in Test Events (staging first, then the production test lead)
For each event, check both Test Events and the outbox row (meta_capi_events).
| ID | Event | Expected in Test Events | Payload assertions (outbox payload) |
|---|---|---|---|
| T6 | QuizStart (P2, if built) |
Browser + server, same Event ID da:quizstart:<SESSION_ID> |
action_source=website. The URL is the quiz page path. user_data only has zp/ct/st/country/IP/UA/fbc/fbp. |
| T7 | Lead |
Browser (fired on the scheduler page after the redirect) + server, same Event ID da:lead:<LEAD_ID>, one of them marked deduplicated |
action_source=website. event_source_url = https://dogacademy.org/…book-training (never /livewire/message/…). user_data has em, ph, fn, ln, ct, st, zp, country, external_id, client_ip_address, client_user_agent, fbc, fbp. custom_data holds only allowlisted keys, identical in browser and server. |
| T8 | Schedule (self-booked) |
Browser + server, Event ID da:schedule:<LEAD_ID>, deduplicated |
The same user_data rules. The IP and user agent come from the booking request. custom_data = content_name: free_consultation, content_category, in_service_area. Rescheduling the slot creates no new event. |
| T9 | No legacy events | During the whole quiz walk, no event arrives without an Event ID except PageView, and Lead arrives once (as a deduplicated pair) |
Legacy send-fb-event names are gone (DEV-SPEC §5.4) |
How to check deduplication:
- Test Events lets you "verify that you've deduplicated events correctly by seeing which events were processed and deduplicated" [D8].
- After production traffic, go to Events Manager → event → View details → Event deduplication. The overlap for the
event_idkey should be ≥90% forLeadandSchedule[H20]. - The rule being tested: the same
event_nameand the same ID, within 48 hours. Meta favours the browser event if both arrive within 5 minutes [D1][D9].
4. Stage-feed checks (staging)
Use signed requests. A test helper can build the signature:
TS=$(date +%s); BODY='<JSON_PAYLOAD>'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$(cat /run/secrets/da_stage_hmac)" | sed 's/^.* //')
curl -sS -X POST https://<STAGING_HOST>/api/meta/stage-events \
-H "Content-Type: application/json" -H "X-DA-Timestamp: $TS" -H "X-DA-Signature: sha256=$SIG" \
--data "$BODY"
| ID | Test | Pass condition |
|---|---|---|
| T10 | Missing or wrong signature; a timestamp 10 minutes old | 401, and nothing stored |
| T11 | Send the same (source, event_id) twice |
The second reply is duplicate; exactly one ledger row and one outbox row |
| T12 | consultation_scheduled for a lead with no website Schedule |
Server-only Schedule with action_source=phone_call and no IP or user agent. For a lead that has one: not sent again. |
| T13 | consultation_completed with channel=phone, then another lead with channel=video |
ConsultationCompleted with phone_call and with other; Event ID da:consult:<LEAD_ID>; no IP or user agent |
| T14 | purchase with payment_channel=phone, then payment_link, then website |
Purchase with phone_call and with other, each with value, currency=USD, order_id and content_name=trained_for_life. The website one is skipped (the site checkout sends it). |
| T15 | purchase for a lead with no consultation event |
ConsultationCompleted is emitted first (the consultation time, or the purchase time minus 1 s), then Purchase |
| T16 | refund, no_show, cancelled, disqualified |
stored_not_sent; no outbox row |
| T17 | occurred_at 8 days ago; also an outbox row whose event_time is 8 days old |
too_old; never sent. The job marks it dead without making an HTTP call [D1]. |
| T18 | CSV import --dry-run, then a real run, with the same rows as T12–T16 |
Identical results to the webhook. Unmatched rows are listed. |
5. Privacy, security and failure checks
| ID | Test | Pass condition |
|---|---|---|
| T19 | Request pages with the header Sec-GPC: 1, and complete a lead |
optout_suppress: no pixel, no _fbc/_fbp set, outbox rows skipped. optout_ldu: fbq('dataProcessingOptions',['LDU'],0,0) runs before init, and CAPI rows carry data_processing_options=["LDU"] with country and state 0 [D24]. |
| T20 | Submit the PSD consultation form; send a purchase with a PSD product line |
Nothing is sent to Meta |
| T21 | Unit test: build an event whose custom_data contains "service dog" |
The guard throws. It is logged without PII. The quiz or booking flow still completes. |
| T22 | Run the log scan below after the staging tests | No matches. The token exists only in the secret store. |
| T23 | Staging with an invalid token | Graph code 190 produces an alert and rows go failed. Restore the token, run meta:capi:flush, and the rows are sent. |
| T24 | Force an HTTP 500 (fake) | The job retries on the backoff schedule. After the last try the row is dead and an alert fires. |
The T22 log scan: tokens, token-like strings and email addresses must never appear in the Meta log channel.
grep -RinE 'access_token|EAA[A-Za-z0-9]{20}|@' storage/logs/meta_capi* && echo "FAIL" || echo "PASS"
6. Meta-side dataset checks (before launch)
GET /v25.0/481578137302100?fields=is_consolidated_containerreturnstrue. That allows offline events on this dataset [D12].- Events Manager → dataset → Settings shows no restricted data source category [H27]. Diagnostics shows no prohibited-information notices [H25]. Custom events are reviewed if Meta asks [H26].
- Manage integrations: no Meta-enabled CAPI, Gateway or partner CAPI integration on this dataset [H23].
- Custom conversions exist before paid traffic starts, because offline custom conversions only count data received after they are created [H14]. Create each one once its source event has reached
481578137302100(the §8 test lead sendsScheduleandConsultationCompleted). The names are neutral:- "Schedule – in service area";
- "Consultation completed";
- optional "TFL purchase", created after the first real sale.
7. Quality targets after 48 hours of production traffic
The site already has organic and Google traffic, so this can finish before any Meta spend.
| Metric | Where | Target |
|---|---|---|
EMQ Lead |
Events Manager → event → View details → Event matching [H19][H20] | ≥ 8.0 target; 7.5 is the minimum to launch. Meta publishes no pass mark; EMQ covers website events only [H19][D8]. |
EMQ Schedule |
same | ≥ 8.0 target; minimum 7.5 |
EMQ PartialLead / QuizStart |
same | ≥ 7.0 / informational |
| Match-key coverage for offline events (no EMQ exists) | outbox query, §9 C | em ≥ 99%, ph ≥ 95%, external_id 100%, fbc on ≥ 90% of Meta-click leads |
| Event coverage (CAPI vs pixel) | Event coverage tab [H20] | Meta: aim for ≥ 75%. Ours: ≥ 90% for Lead and Schedule, and server count ≥ browser count |
| Dedup-key overlap | Event deduplication tab [H20] | ≥ 90% for Lead and Schedule |
| Freshness | Data freshness tab [H20]; outbox sent_at − event_time |
Web p95 < 5 min; stage p95 < 1 h (webhook) or < 24 h (CSV); 0 rows too_old |
| Diagnostics | Events Manager Diagnostics | 0 open errors |
To pull these programmatically (read-only; the token needs the dataset permission [D18]):
curl -sG "https://graph.facebook.com/v25.0/dataset_quality" \
--data-urlencode "dataset_id=481578137302100" \
--data-urlencode "fields=web{event_name,event_match_quality,event_coverage,dedup_key_feedback,data_freshness}" \
--data-urlencode "access_token@/run/secrets/meta_read_token"
The field names come from Meta's reference [D18], but the subfields are not listed there. If the default response is thin, expand the nodes using the reference. The documented EMQ example uses web{event_match_quality{match_key_feedback{identifier,potential_aly_acr_increase{percentage,description}}},event_name} [D18].
8. End-to-end labelled test lead (production; operator-approved, one lead)
Before you start:
- Every staging test (T1–T24) passes.
- Matthew has flagged the test identity in the CRM as "TEST – do not work".
META_TEST_LEAD_EMAIL_REGEXandMETA_TEST_EVENT_CODE_PRODare set.- Test Events is open on
481578137302100.
Steps:
-
In Test Events → website events → Open website, enter:
https://dogacademy.org/in-person-dog-training/private-training?utm_source=facebook&utm_medium=paid_social&utm_campaign=capi_test&campaign_id=0&adset_id=0&ad_id=0&fbclid=CAPITEST<YYYYMMDD>Expect a
PageView(the program page now has the pixel) and_fbccontaining the test fbclid. The fbclid is synthetic, so this only checks the format. -
Click Book Training and complete the quiz with the test identity. Expect
PartialLeadif built, thenLeadas browser + server with one deduplicated. Write down theevent_idandfbtrace_id. -
On the scheduler, book a slot. Expect
Scheduleas browser + server, deduplicated. -
Matthew's team marks the consultation completed, or the CRM sandbox posts
consultation_completedwith"test": true. Expect a serverConsultationCompletedwithphone_callorother. -
Do not test
Purchasein production. It would put a fake purchase into the dataset.Purchaseis proven on staging (T14/T15) and then on the first real sale through §9. -
Clean up:
- cancel the booking;
- close the test lead in the CRM;
- add its
event_ids to the reconciliation exclusions (test events stay in the dataset); - file the evidence.
Pass: steps 1–4 all show the expected events with correct payloads, deduplication works, and no alert fired.
9. Seven-day CRM ↔ app ↔ Meta reconciliation
Run it daily. The gate is the first full 7 days after launch (G10).
The SQL is MySQL 8. Adapt leads and completed_at to DA's schema.
SET time_zone = '+00:00';
SET @to = UTC_DATE(); -- today 00:00 UTC, exclusive
SET @from = @to - INTERVAL 7 DAY;
-- A. What the app queued and sent, per event and action source
SELECT e.event_name, e.action_source,
COUNT(*) AS outbox_rows,
SUM(e.status = 'sent') AS sent,
SUM(e.status = 'sent' AND e.events_received = 1) AS acked,
SUM(e.status IN ('failed', 'dead')) AS failed_or_dead,
SUM(e.status = 'skipped') AS skipped_by_rule,
ROUND(AVG(CASE WHEN e.status = 'sent'
THEN TIMESTAMPDIFF(SECOND, FROM_UNIXTIME(e.event_time), e.sent_at) END) / 60, 1) AS avg_delay_min
FROM meta_capi_events e
WHERE e.event_time >= UNIX_TIMESTAMP(@from) AND e.event_time < UNIX_TIMESTAMP(@to)
AND e.test_event_code IS NULL
GROUP BY e.event_name, e.action_source
ORDER BY e.event_name, e.action_source;
-- B. Completed leads without a sent Lead event (every row must be explained by opt-out or exclusion)
SELECT l.id AS lead_id, i.meta_opt_out, i.meta_excluded, e.status
FROM leads l
LEFT JOIN lead_meta_identities i ON i.lead_id = l.id
LEFT JOIN meta_capi_events e ON e.event_id = CONCAT('da:lead:', l.id)
WHERE l.completed_at >= @from AND l.completed_at < @to
AND (e.id IS NULL OR e.status NOT IN ('sent', 'skipped'));
-- C. Stage-feed ledger outcomes (compare "received" with the CRM/dialer/billing export for the same UTC window)
SELECT s.stage,
COUNT(*) AS received,
SUM(s.result = 'queued') AS queued_for_meta,
SUM(s.result = 'already_sent') AS already_sent_by_site,
SUM(s.result = 'ignored_negative') AS negative_not_sent,
SUM(s.result = 'too_old') AS too_old,
SUM(s.result = 'unmatched') AS unmatched,
SUM(s.result = 'excluded') AS excluded
FROM lead_stage_events s
WHERE s.occurred_at >= @from AND s.occurred_at < @to AND s.is_test = 0
GROUP BY s.stage;
-- D. Stage coverage: queued stage changes that Meta acknowledged
SELECT s.stage,
SUM(s.result = 'queued') AS queued,
SUM(s.result = 'queued' AND e.status = 'sent' AND e.events_received = 1) AS acked,
ROUND(100 * SUM(s.result = 'queued' AND e.status = 'sent' AND e.events_received = 1)
/ NULLIF(SUM(s.result = 'queued'), 0), 1) AS coverage_pct
FROM lead_stage_events s
LEFT JOIN meta_capi_events e ON e.event_id = CASE s.stage
WHEN 'consultation_scheduled' THEN CONCAT('da:schedule:', s.lead_id)
WHEN 'consultation_completed' THEN CONCAT('da:consult:', s.lead_id)
WHEN 'purchase' THEN CONCAT('da:purchase:', s.order_id)
END
WHERE s.occurred_at >= @from AND s.occurred_at < @to AND s.is_test = 0
GROUP BY s.stage;
-- E. Match-key coverage for offline events (EMQ does not exist for them)
SELECT e.event_name,
COUNT(*) AS sent,
ROUND(100 * AVG(JSON_CONTAINS_PATH(e.payload, 'one', '$.user_data.em')), 1) AS em_pct,
ROUND(100 * AVG(JSON_CONTAINS_PATH(e.payload, 'one', '$.user_data.ph')), 1) AS ph_pct,
ROUND(100 * AVG(JSON_CONTAINS_PATH(e.payload, 'one', '$.user_data.external_id')), 1) AS external_id_pct,
ROUND(100 * AVG(JSON_CONTAINS_PATH(e.payload, 'one', '$.user_data.fbc')), 1) AS fbc_pct
FROM meta_capi_events e
WHERE e.status = 'sent' AND e.action_source <> 'website'
AND e.event_time >= UNIX_TIMESTAMP(@from) AND e.event_time < UNIX_TIMESTAMP(@to)
GROUP BY e.event_name;
Meta side. Read-only. The stats endpoint returns data "up to seven days from the request time" [D19], so pull it daily and keep the results.
curl -sG "https://graph.facebook.com/v25.0/481578137302100/stats" \
--data-urlencode "aggregation=event" \
--data-urlencode "event_source=SERVER_ONLY" \
--data-urlencode "start_time=<UNIX_FROM>" \
--data-urlencode "end_time=<UNIX_TO>" \
--data-urlencode "access_token@/run/secrets/meta_read_token"
CRM side. Matthew's export of stage counts for the same UTC window, per stage, taken from the CRM, dialer and billing systems.
| Comparison | Tolerance |
|---|---|
CRM export vs received (C) |
Equal, apart from explained edge-of-window timing |
failed_or_dead (A) |
0 open. Each one is investigated, fixed and re-flushed within 6 days. |
| Leads without a Lead event (B) | 0 unexplained |
| Stage coverage (D) | ≥ 98% within 24 hours; 100% of eligible rows within 6 days |
unmatched (C) |
≤ 1%, and each one is resolved |
acked (A) vs Meta /stats SERVER_ONLY per event |
Within ±2% (exclude test events) |
| Match keys (E) | em ≥ 99%, ph ≥ 95%, external_id 100% |
Reporting rules:
- Judge performance on CRM cohorts: cost per completed consultation and per sale, by lead week, using the stored
campaign_id/adset_id/ad_id. - Meta's Schedule results are capped by the 7-day window [H7].
- Never add CRM-mode
Sale ClosedtoPurchase.
10. Go/no-go gate
| Gate | Check | Pass | Owner | Evidence |
|---|---|---|---|---|
| G0 | Access | The operator is admin on the Business, dataset, ad account and Page, and our connector can read dataset details and stats | Operator + DA admin | Readback |
| G1 | Dataset health | Not consolidated → fail. Any restricted category or prohibited-info notice → fail. More than one server sender → fail. (§6) | AdMaker | Screenshots + API output |
| G2 | Domain | dogacademy.org verified in DA's Business |
DA admin | Screenshot |
| G3 | Pixel coverage | T1–T5 pass | AdMaker | Test Events + curl |
| G4 | Web events | T6–T9 pass on staging and production; deduplication confirmed | AdMaker | Test Events screenshots + outbox IDs |
| G5 | Stage feed | T10–T18 pass on staging. The production webhook has received real stage changes for 3 business days with 0 dead. |
DA dev + AdMaker | Outbox report |
| G6 | Privacy, security, failure | T19–T24 pass; the secret is in the secret store; alerts have been tested | DA dev | Log grep + alert screenshot |
| G7 | Ads Manager | Gate 0-B: Schedule can be selected on dataset 481578137302100; Gate 0-A recorded; custom conversions "Schedule – in service area" and "Consultation completed" exist (§6) |
AdMaker | Screenshots |
| G8 | Quality | After 48 hours: EMQ Lead and Schedule ≥ 7.5, coverage ≥ 75% (target 90%), dedup ≥ 90%, freshness in target | AdMaker | Events Manager / Dataset Quality API |
| G9 | Test lead | §8 passes | AdMaker + Matthew | QA log |
| Launch | G0–G9 all green | Operator approves | Operator | — |
| G10 | Day-7 reconciliation | Every §9 tolerance met before any budget increase | AdMaker + DA dev | Reconciliation output |
11. Ongoing cadence
| When | Check |
|---|---|
| Real time | Alerts: dead, 190/200/10, failure rate > 2% per hour, no Lead sends for 3 business hours |
| Daily | meta:capi:reconcile; pull /stats; confirm no row is close to the 7-day limit |
| Weekly | Dataset Quality API: EMQ, coverage, dedup, freshness. §9 in full. Custom conversions still Active. CRM cohort report (cost per consultation and per sale). |
| Monthly | Lag distribution (share of consultations and sales within 7 and 28 days of the lead); stage rates; rerun Gate 0-A (Meta's qualified-leads support may change); rotate or review the token holder list |