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.


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.

Upgrade paths, in order:

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

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

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

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

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

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

Why the answer is no:

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

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

The Help Center says yes:

The developer docs say no:

Other clues:

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

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

Gate 0-A: is the qualified-leads goal available on Website?

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

Gate 0-B: which conversion events does a Website ad set offer?

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:


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:


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:

Value and currency appear only on Purchase and SaleKept:

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].

The fbc format is fb.{subdomainIndex}.{creationTimeMs}.{fbclid}. Meta's rules [D4]:

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:

3.4 external_id

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:


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

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

    // app/Http/Middleware/EncryptCookies.php
    protected $except = ['_fbc', '_fbp', '_fbi'];
    
  2. Proxies. The app sits behind an AWS ELB and nginx [I1]. Set App\Http\Middleware\TrustProxies::$proxies to the ELB/VPC CIDR, or '*' only if the instances can be reached solely through the ELB. $request->ip() then returns the visitor's IP, not the load balancer's.

    • Cloudflare only proxies www. If the apex is ever proxied, read CF-Connecting-IP.

4.3 Middleware: capture the click ID on every page

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

<?php

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 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:

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:

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):

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():

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).

DA checkout, if TFL is ever paid on dogacademy.org:

4.9 Browser/server deduplication

4.10 Logging and alerts

Logging:

Alerts (MetaAlerts, posting to the Slack webhook plus email):

4.11 Automated tests to ship with the build


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

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

5.3 Domain verification

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

5.6 Heyflow (training.dogacademy.org)

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:


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:

Responses:

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)

  1. Validate. Insert into lead_stage_events with a unique (source, event_id). If it is a duplicate, return duplicate.

  2. Resolve the lead. Match on da_lead_id. Failing that, match the normalized email or phone to the most recent lead created before occurred_at within 60 days. If nothing matches, the result is unmatched and an alert goes out.

  3. Excluded and opted-out leads are stored with result excluded and never sent.

  4. Negative stages (refund, cancelled, no_show, disqualified) are stored with result ignored_negative and never sent to Meta (§7.1).

  5. Too old. If occurred_at is more than 6 days 23 hours ago, the result is too_old and nothing is sent [D1][D7].

  6. Map to Meta events in offline mode, the default:

    Stage Meta event action_source event_id Notes
    consultation_scheduled Schedule phone_call da:schedule:{lead_id} If the website Schedule already exists for the lead: result already_sent, nothing is sent
    consultation_completed ConsultationCompleted phone_call if channel=phone, otherwise other da:consult:{lead_id} First completion per lead only
    purchase Purchase phone_call if payment_channel=phone; other for payment_link/other da:purchase:{order_id} If payment_channel=website, the site checkout already emitted it: result already_sent. A webhook can never send a website event, because it has no browser user agent or URL [D2]
  7. Backfill. If a purchase arrives before any consultation event for the lead, first emit ConsultationCompleted. Use the consultation time if it was supplied, otherwise the purchase time minus 1 second. The sale is made on the consultation call [I3]. In crm mode this is required: "all previous stages should have been sent beforehand" [I3].

  8. Suppress PSD sales. A purchase with product_line indicating PSD or service-dog training is never sent (§7.3).

6.3 Stage definitions DA must pin down (Matthew)

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

6.4 Fallback A: daily CSV or Google Sheet import

6.5 Fallback B: Zapier or Make

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]:

Rules:

Meta's documented gates (for Instant Forms) [I3][D16]:


7. Rules

7.1 No negative events

7.2 Refunds: the "sale kept" option

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

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

7.3 Never send

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:

7.4 Accuracy and timing

7.5 Consent


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

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

9. QA

See QA-RUNBOOK.md. It covers:

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:


Appendix A: open questions

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

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

Dog Academy CAPI: QA runbook and go/no-go gate

Version 1.0, 2026-09-28.

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:

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:

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)

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:

Steps:

  1. 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 _fbc containing the test fbclid. The fbclid is synthetic, so this only checks the format.

  2. Click Book Training and complete the quiz with the test identity. Expect PartialLead if built, then Lead as browser + server with one deduplicated. Write down the event_id and fbtrace_id.

  3. On the scheduler, book a slot. Expect Schedule as browser + server, deduplicated.

  4. Matthew's team marks the consultation completed, or the CRM sandbox posts consultation_completed with "test": true. Expect a server ConsultationCompleted with phone_call or other.

  5. Do not test Purchase in production. It would put a fake purchase into the dataset. Purchase is proven on staging (T14/T15) and then on the first real sale through §9.

  6. 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:

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