24 · Funnel & Tracking Runbook — As-Built
Status: ✅ Production, last updated 2026-05-27 after CAPI ship Audience: Developer / agent picking up the funnel for the first time Replaces: “future plan” sections of #13 tracking-events, #14 meta-ads-attribution, #17 data-flow-diagram, #18 gtm-tag-setup
This doc is the single source of truth for what’s actually shipped. The other tracking docs describe the journey. This one describes the destination.
0 · TL;DR — the conversion signal stack
Three independent layers, all firing the same conversion events with the same event_id = submission_id so Meta dedupes them as one logical conversion:
Browser Server (Make.com)
┌──────────────────────────────────┐ ┌────────────────────────────────────┐
│ 1. Inline Meta Pixel │ │ 3. Make webhook → Graph API CAPI │
│ - Pixel base (PageView) │ │ - Lead / Subscribe │
│ - ViewContent on landings │ │ - CompleteRegistration │
│ - InitiateCheckout on modals │ │ - hashed em + ph + fbc │
│ - Contact on WhatsApp tap │ │ - matches Meta user graph @90%+ │
│ - Lead/Sub/CR on /thank-you │ │ - SURVIVES ad-block / iOS ITP │
└──────────────────────────────────┘ └────────────────────────────────────┘
│ │
└────────────────┬─────────────────────────┘
▼
┌──────────────────────────────────────┐
│ 2. Meta-hosted CAPI Gateway │
│ - auto-bridges browser events │
│ - "Web-only" dataset │
│ - low match quality (no PII) │
│ - already active, leave alone │
└──────────────────────────────────────┘
Why three layers: each one survives a different failure mode. Browser Pixel dies under ad-blockers/DNS sinkholes (~25–40% of paid traffic). CAPI Gateway dies when the browser Pixel never loaded (so it has nothing to bridge). Server-side CAPI from Make fires no matter what — every form submission goes through it.
Industry-typical loss without CAPI = 25–40% of conversions. We’ve closed that gap.
1 · File-by-file map — where every event is fired
| Layer | File | Role | Lines worth knowing |
|---|---|---|---|
| HTML head | src/layouts/Layout.astro | Sets window.__KBP__ = {variant, experiment, mechanism} and pushes the same trio to dataLayer before GTM/Pixel init | 95–112 |
| Pixel base | packages/analytics/src/components/AnalyticsHead.astro | Loads fbevents.js inline, fires PageView automatically | 107 |
| Attribution | src/conversion/attribution.ts | Captures UTMs/click-IDs into sessionStorage, first-touch merge, derives tier/country from variantId | tierFromVariant 204–210, countryFromVariant 211–214, buildAttribution 217–241 |
| Page-load events | src/scripts/page-init.ts | Calls fireViewContent on landing pages, fireInitiateCheckout on [data-modal-open] clicks, fireContact on WhatsApp FAB | 60–70, 115–126, 109–111 |
| Pixel event helpers | src/scripts/pixel-events.ts | Inline fbq('track', …) wrappers + leadValue(tier, country) mapping + segmentParams() shared payload | leadValue 46–63, segmentParams 71–95, fireSubscribe 121–129 |
| Conversion event | src/scripts/conversion-complete.ts | Fires Lead OR Subscribe + CompleteRegistration on /thank-you, pushes generate_lead to dataLayer | 54–93 |
| Form payload | src/conversion/attribution.ts → buildLeadPayload | Wraps form values into the envelope sent to Make webhook | 265–315 |
| Form submit | src/conversion/submit.ts | POSTs payload to Make webhook URL | full file |
| Webhook URL | src/config/integration.ts | Single source of truth for the Make webhook URL | full file |
| Analytics config | src/config/analytics.ts | GTM ID + Meta Pixel ID, consumed by <AnalyticsHead> | full file |
| GA4/GTM events | src/conversion/tracking.ts | form_view/form_start/form_step/form_submit/form_success dataLayer pushes (NOT Pixel) | full file |
| Backfill script | scripts/capi-backfill.ts | One-off batch sender — pushes historical Sheet leads through Graph API | full file |
| Lead-flow tester | scripts/test-lead-flow.ts | Fires representative test payloads at Make webhook for QA | full file |
2 · Make scenario — KARADA BP (id 4743595)
URL: https://us1.make.com/237825/scenarios/4743595/edit
Webhook: https://hook.<your-host>/<webhook-id>
Webhook (id 1)
└── Router (id 6) — splits payload to 4 parallel branches
├── Slack (id 5) → notification to #karada-leads
├── Google Sheet (id 3) → addRow to Leads tab
├── amoCRM HTTP (id 8) → POST /api/v4/leads/complex
└── Meta CAPI (3 modules in sequence, each filtered)
├── id 10 — Lead filter: tier ≠ lead_magnet
├── id 11 — Subscribe filter: tier = lead_magnet
└── id 12 — CompleteReg. filter: funnel_mechanism = registration
CAPI module body (template)
Each of the 3 CAPI HTTP modules uses this body template (event_name + custom_data differ per module):
{
"data": [
{
"event_name": "Lead",
"event_time": {{formatDate(parseDate(1.submitted_at); "X")}},
"event_id": "{{1.submission_id}}",
"action_source": "website",
"event_source_url": "https://x5.karada.kz{{ifempty(1.attribution.page_path; "/")}}",
"user_data": {
"em": ["{{if(1.lead.email; sha256(lower(trim(1.lead.email))); "")}}"],
"ph": ["{{if(1.lead.phone; sha256(replace(1.lead.phone; "[^0-9]"; "")); "")}}"],
"client_user_agent": "{{1.meta.user_agent}}"
},
"custom_data": {
"value": {{if(1.tier = "exclusive"; if(1.country = "KG"; 250000; 1000000); if(1.country = "KG"; 51000; 102000))}},
"currency": "{{if(1.country = "KG"; "KGS"; "KZT")}}",
"content_name": "big-sales-{{ifempty(1.tier; "unknown")}}",
"content_category": "{{ifempty(1.country; "unknown")}}"
}
}
],
"access_token": "<EAA…>"
}
Module-by-module differences:
| Module | event_name | custom_data | Filter |
|---|---|---|---|
| 10 | "Lead" | with value + currency | {{1.tier}} ≠ lead_magnet |
| 11 | "Subscribe" | no value + currency | {{1.tier}} = lead_magnet |
| 12 | "CompleteRegistration" | with value + currency | {{1.funnel_mechanism}} = registration |
Why three separate modules (vs one conditional JSON body):
- Each module has a fixed payload — no brittle conditional JSON inside IML templates (Make’s
nullkeyword breaks JSON, and triple-escaped quotes break the expression engine, learned the hard way) - Filters are explicit and testable in the Make UI
- Disable one without touching the others when debugging
- Mirrors the inline-Pixel logic 1:1 → trivial to reason about dedup
Why HTTP module not native facebook-conversions-api app: same predictable pattern as the existing amoCRM HTTP module, full control over payload, no dependency on the app’s evolving connection structure. Native app would be a future cleanup if SHA-256 automation becomes valuable.
3 · Event matrix — what fires per funnel path
Source legend
- Browser =
fbq('track', …)frompixel-events.tsorconversion-complete.ts - CAPI = Make HTTP module → Graph API
- CAPI Gateway = auto-bridged from browser, low match quality, ignore
Per page load (any non-utility landing)
| Event | Source | Fires when |
|---|---|---|
PageView | Browser | Every page (AnalyticsHead.astro:107) |
ViewContent | Browser | Landing pages with a baked variantId (page-init.ts:62) |
Per CTA interaction
| Event | Source | Trigger |
|---|---|---|
InitiateCheckout | Browser | Any [data-modal-open] click (page-init.ts:117) |
Contact | Browser | [data-cta="whatsapp-fab"] tap (page-init.ts:109) |
Per form submission — the matrix
Each row = 1 submission. Server CAPI events all dedupe against the browser event by event_id = submission_id.
| Variant | Tier | Mechanism | Browser fires | CAPI fires |
|---|---|---|---|---|
home | vip | registration | Lead + CompleteRegistration | Lead + CompleteRegistration |
vsl | vip | registration | Lead + CompleteRegistration | Lead + CompleteRegistration |
kg-home | vip | registration | Lead + CompleteRegistration | Lead + CompleteRegistration |
kg-vsl | vip | registration | Lead + CompleteRegistration | Lead + CompleteRegistration |
exclusive | exclusive | application | Lead | Lead |
guide | lead_magnet | lead-magnet | Subscribe | Subscribe |
| Any | any | quick-lead | Lead (or Subscribe if lead_magnet) | Lead (or Subscribe) |
Variant → tier → country derivation
// src/conversion/attribution.ts:204
tierFromVariant('home') = 'vip' tierFromVariant('exclusive') = 'exclusive'
tierFromVariant('vsl') = 'vip' tierFromVariant('guide') = 'lead_magnet'
tierFromVariant('kg-home') = 'vip' tierFromVariant('') = '' (falls through to vip in leadValue)
countryFromVariant('kg-*') = 'KG' countryFromVariant(anything else) = 'KZ'
leadValue(tier, country) — the canonical value map
This MUST stay in lock-step between pixel-events.ts (browser) and the Make CAPI modules’ IML expression (server). Change one, change the other.
KZ KG
vip / standard / '' 102_000 KZT 51_000 KGS
exclusive 1_000_000 KZT 250_000 KGS
lead_magnet 0 (omitted) 0 (omitted)
Subscribe + lead_magnet events do not include value/currency — Meta rejects value: 0 on monetary events. The website’s segmentParams() helper auto-omits when value=0; the Make Subscribe module simply omits the value key from its template.
4 · Adding a new funnel / landing — checklist
Use this when scaffolding a new variant (e.g. you want to test lp-c or add a Russian-language exclusive funnel ru-exclusive).
Code-side
- Page file
src/pages/<route>.astrowithvariantIdandmechanismconsts - Variant in
tierFromVariant(src/conversion/attribution.ts:204) — return the right tier for the new variant ID - Country in
countryFromVariant(src/conversion/attribution.ts:211) — KZ vs KG (prefixed withkg-orkz-) - leadValue mapping (
src/scripts/pixel-events.ts:46) — if introducing a new tier, add a branch - Funnel content in
src/lib/content.tsandsrc/conversion/mechanisms.ts - PageModal with
mechanism,variantId,formIdprops - Test in
test-lead-flow.ts— add a scenario entry so QA can fire a representative payload
Make scenario-side (if introducing a new tier or mechanism)
Only needed when adding a new tier that needs different leadValue, or a new mechanism that should fire CompleteRegistration:
- Update module 10 (Lead) value expression:
{{if(1.tier = "<new_tier>"; <new_value_kz>; ...)}} - Update module 12 (CompleteRegistration) filter if a new mechanism should trigger it
- If introducing a new tier that shouldn’t fire Lead (like lead_magnet), add it to the Subscribe module’s filter
Mostly the existing 3 modules don’t need touching for new landings — they already handle vip/exclusive/lead_magnet × KZ/KG × all mechanisms via filters and conditionals.
Meta-side
- (Optional) Set up a separate Custom Conversion in Events Manager for the new variant if you want isolated reporting (uses
content_name=big-sales-<tier>filter) - Update AEM event ranking if the new funnel uses a different primary event (e.g. ranking Subscribe above Lead when launching guide-magnet campaigns)
- Build a Lookalike audience from the new variant’s events once you have ~100 conversions
QA
-
bun run scripts/test-lead-flow.ts <new-variant>— fires through Make webhook end-to-end - Events Manager → Test Events → verify both Browser + Server events arrive with matching
event_id - Events Manager → Diagnostics → match quality 7+/10 within 24h
5 · Verification playbook
Three tools, three different questions answered.
Tool 1 — Meta Pixel Helper (Chrome extension)
Question answered: “Is the browser firing what I think it’s firing?”
- Install Meta Pixel Helper
- Visit any landing page → click extension icon
- See live log: events fired + full params + eventID
- If params are wrong here → fix the code
- If params are right here but missing in Meta → check next tool
Tool 2 — Events Manager → Test Events tab
Question answered: “Is Meta receiving the events I’m firing?”
- Open:
https://business.facebook.com/events_manager2/list/pixel→ pixel 662311819389495 → Test Events tab - Copy the Test Event Code at the top (e.g.,
TEST12345) - Visit landing with
?test_event_code=TEST12345appended → events appear with timestamps - For server CAPI verification: temporarily add
"test_event_code": "<code>"to the CAPI module body, fire test, remove - Dedup verification: same submission should produce one row labeled “Browser + Server · Deduplicated”
Tool 3 — Events Manager → Diagnostics tab
Question answered: “Is Meta’s optimization layer actually using these events?”
- Same pixel → Diagnostics tab
- Match Quality 0–10 per event:
- Lead with browser only → ~3–5/10
- Lead with CAPI + hashed PII → 7–9/10 target
- “Issues” count should be 0% within 7 days of any data-quality fix
Tool 4 — Events Manager → Overview
Question answered: “How much volume and from which source?”
- Overview tab shows last 7d event count per event type
- “Connection method” column shows Browser / Server / Both per event row
- After CAPI ship, every Lead should show Both
6 · Backfill procedure (one-off)
When to use: discovered missing data, lost a week of leads to a broken Pixel, doing a one-time migration.
When NOT to use: forward-only events flow through Make automatically, no backfill needed for routine ops.
Steps
# 1. Export the Leads tab as TSV
# Google Sheets → Leads → File → Download → "Tab-separated values (.tsv)"
# Save to e.g. ~/Downloads/leads.tsv
# 2. Get the CAPI access token (Events Manager → CAPI → Manage tokens, or
# rotate fresh per CAPI hygiene). NEVER commit it.
# 3. Dry-run to preview the first event
cd 28_karada-bp
META_CAPI_TOKEN='EAA…' bun run scripts/capi-backfill.ts ~/Downloads/leads.tsv --dry
# 4. Optional: send to Meta's Test Events tab first
META_CAPI_TOKEN='EAA…' bun run scripts/capi-backfill.ts ~/Downloads/leads.tsv --test-code TESTXYZ
# 5. Real send (default --days=7, override with --days=N)
META_CAPI_TOKEN='EAA…' bun run scripts/capi-backfill.ts ~/Downloads/leads.tsv
Idempotent — event_id = original submission_id so re-running doesn’t double-count. Meta dedupes server vs browser vs previous-backfill against the same id.
Meta’s freshness windows (matters for choosing --days):
- 0–7 days: full optimization weight + attribution (best window)
- 7–62 days: attribution only (still shows in reports, doesn’t train the algo)
- >62 days: rejected by Meta
What gets sent per row
Same logic as the live flow (mirrors Make’s 3 modules):
- tier ≠ lead_magnet →
Leadwith value + currency - tier = lead_magnet →
Subscribewithout value - mechanism = registration AND tier ≠ lead_magnet → also
CompleteRegistration
7 · Token rotation — security hygiene
CAPI tokens are bearer credentials. Treat them like passwords.
When to rotate
- Any time a token appears in chat history, logs, screenshots, or non-secret stores
- Quarterly as routine hygiene
- Immediately if a team member with token access offboards
How to rotate
- Events Manager → pixel
662311819389495→ Settings → Conversions API - Manage access tokens → identify the token by name (e.g., “Make integration”)
- Revoke the old token
- Generate a new token → copy once (Meta never re-displays it)
- Update Make: scenario 4743595 → modules 10, 11, 12 → replace
access_tokenvalue in the body template of each - Verify: run
bun run scripts/test-lead-flow.ts vsl→ confirm execution still succeeds (no 401 / 403 from Graph API)
What lives where
| Secret | Where it lives | Rotation impact |
|---|---|---|
| Meta CAPI token | Make scenario body templates (3 modules) | Update all 3 module bodies after rotation |
| amoCRM bearer | Make scenario amoCRM HTTP module headers (1 module) | Update 1 module after rotation |
| Make webhook URL | src/config/integration.ts (committed) | Repo-level secret, rotate via Make scenario UI |
| Pixel ID | src/config/analytics.ts (committed) | Not a secret, public |
8 · Troubleshooting — common failure modes
Symptom: Meta Diagnostics shows “X% affected — value field is missing”
Likely causes:
- A legacy GTM Lead tag is still firing with empty
{}payload → pause it in GTM lead_magnettier is firing Lead withvalue: 0→ checkconversion-complete.ts:54-93routes lead_magnet to Subscribe, not Lead- A new tier was added without updating
leadValue()→ defaults to vip values, which IS valid; not a real bug
Diagnostic window: 7-day rolling, warnings decay automatically once the source is fixed.
Symptom: Make scenario auto-deactivated by maxErrors: 3
Cause: 3+ executions failed in a row, usually from malformed JSON in an HTTP body template after a recent edit.
Recovery:
- Open scenario in Make UI → see error in red banner
- Inspect the failed execution → identify the broken module
- Fix the body template (most common: unescaped quote inside an IML expression)
- Re-activate the scenario manually (toggle in Make UI)
Symptom: Backfill script reports events_received < sent
Cause: Some events failed Meta validation (usually missing required field, malformed fbc, expired event_time).
Diagnostic: Meta’s response body in stderr includes per-event error messages. Inspect with fbtrace_id if opening a Meta support ticket.
Symptom: Server-side CAPI events not appearing in Diagnostics
Likely causes:
event_idmismatch with browser Pixel → dedup absorbs server event silently (this is actually fine — it means Meta is dedup’ing as designed; the event still counts, it just doesn’t add a separate row)- CAPI Gateway active AND our server CAPI both firing → check that
event_idis consistent so Meta dedupes both into one - Token invalid → Graph API returns 401, Make execution would error (not silent fail)
Symptom: Inline Pixel never loaded (CSP / ad-blocker)
By design: server CAPI still fires from Make regardless. Match quality bumps because hashed em/ph carry the signal even without the cookie. iOS Safari ITP users are also covered this way.
9 · Lessons learned (don’t relearn the hard way)
Make IML / blueprint
nullkeyword in IML outputs nothing, breaks JSON. Use""or omit the key.- Use single-escape
\"insidejsonStringBodyContent, not\\\"(triple-escape breaks the expression engine). maxErrors: 3auto-deactivates a scenario silently — Make sends an email but it’s easy to miss.- Filters on modules within a route are AND-applied — modules whose filter doesn’t pass are skipped without counting an operation.
Meta CAPI
- Standard event names (Lead / Subscribe / CompleteRegistration) are case-sensitive and must match Meta’s spec exactly.
valueMUST be > 0 on monetary events (Lead / ViewContent / InitiateCheckout / Purchase). Omit instead of sending 0.event_iddedup is per-event-name. Lead + CompleteRegistration with the same id are 2 separate conversions, both counted.fbcformat isfb.1.<ms_timestamp>.<fbclid>— subdomain_index always1for non-fb domains.- Hashing: email →
sha256(lower(trim(email))), phone →sha256(digits_only(phone))(no+, no spaces).
Architecture
- Inline Pixel survives the 25–40% of paid traffic that ad-blocks
googletagmanager.com. Always keep critical events inline. - CAPI Gateway is Meta’s auto-bridge — leave it alone, it complements our server CAPI, dedup handles overlap.
- Don’t replay leads through the Make webhook to backfill — would re-trigger Slack/Sheet/amoCRM as duplicates. Use the standalone script.
10 · Quick reference card
Pixel ID 662311819389495
Make scenario ID 4743595
Make webhook https://hook.<your-host>/<webhook-id>
amoCRM endpoint https://yusup.amocrm.ru/api/v4/leads/complex
amoCRM pipeline 9111838 (Источник = "Таргет", enum_id 436603)
amoCRM city field 706141 (Населенный пункт)
Sheet ID 1vvnVz1o5gsrvbGaiiaNzQclmwHBBt-6DTHDHQ7OkCOg
Graph API version v22.0
Production URL https://x5.karada.kz
Staging URL https://karadabp.flowleads.dev
Standard event optimization priority
1. Lead ← primary, most data, most flexibility
2. CompleteRegistration ← committed users
3. InitiateCheckout ← mid-funnel intent
4. Subscribe ← lead-magnet (future)
5. ViewContent ← top-funnel
6. PageView ← traffic baseline
7. Contact ← WhatsApp tap
8. (slot reserved)
Set this ranking in Events Manager → Overview → Configure Web Events.
Last verified end-to-end: 2026-05-27 — Make execution 1e510d0376c0401aa32ff858426d55b1 succeeded with status 1, CAPI backfill fbtrace_id=AQcj-R8dj4hoMsfhzD6LfXa received all 32 events.