Dog Academy: Meta Conversions API, developer handoff

Download PDF · Download Markdown · Full build spec (code) · event-contract.json

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

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

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

1. What you're building

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

Three hard rules:

2. How it works

Two senders, one dataset Browser and server send the same events; Meta counts each once The visitor’s browser Pixel on every pageSends Lead, Schedule,Purchase, each with an IDKeeps _fbc and _fbp CRM, dialer, billing Calls your site when aconsult is held or asale closes (lead ID) Laravel app (server) Saves the click ID andhashed contact per leadSends the same eventsthrough CAPI webhook (lead ID) same event via CAPI pixel event+ event ID Meta dataset 481578137302100 Matches the two copies by event ID, counts onceOptimizes to Purchase, paid on your siteYour CRM never talks to Meta directly
The browser and your server report the same website events with the same ID. Your CRM tells your site about later stages, and the site sends them on.
  1. Ad click. The visitor arrives with fbclid in the URL. Middleware turns it into the _fbc cookie and keeps _fbp.
  2. Email step. When the quiz saves the lead, your app stores the click IDs, IP, browser and first-touch ad IDs against that lead.
  3. Quiz submitted. The browser fires Lead with eventID = da:lead:{lead_id}. The server queues the same event with the same ID.
  4. Consultation booked. The same pattern, with Schedule and da:schedule:{lead_id}. First booking only.
  5. Paid on the site. The rep sends the pay link on the call. When the customer pays, the confirmation page fires Purchase with eventID = da:purchase:{order_id}, plus the value and currency. The server sends the same event, matched to the lead's saved identity. This is the event the ads optimize to.
  6. Later stages. The CRM, dialer or billing calls POST /api/meta/stage-events. Your app looks up the lead's saved identity and sends ConsultationCompleted or a phone-paid Purchase to Meta, labelled honestly as a phone call or other.

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

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

4. Event contract

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

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

User data:

Hashing: SHA-256, lowercase hex, exactly once.

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

5. Never sent to Meta

6. Examples (placeholders in angle brackets)

Browser:

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

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

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

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

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

Field rules:

Responses:

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

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

8. Decisions for your team

Questions go to Brett. Reference code: the full build spec. The business overview for Matt is here.