The dev-ready sister of the attribution framework. That document is the strategy. This document is the wiring. Every UTM parameter, event name, event payload, and platform integration is spelled out here so the /donate flow can accept paid-media traffic on 25 Oct 2026.
Purpose. By 25 Oct 2026, the /donate flow can accept traffic from paid media and every gift is attributable to source (channel + campaign + creative). Missing this deadline blocks Room C's November ad launch and the Christmas Appeal (Giving Tuesday, 2 Dec 2026).
Ownership. Web-dev (implementation) · AI CMO (spec author, QA) · Carla (sign-off before ad-launch). This document is source-of-truth for what needs to be built; the /attribution-framework is source-of-truth for why.
Governance. 5-gate Marketing Governance Envelope. Attribution is a G2 (Compliance — Privacy Act 1988), G4 (Funnel — Conversion weighting 20/40/40 Story/Trust/Data), and G5 (Best-practice — server-side CAPI, hashed identifiers) concern simultaneously.
100% attribution is not the same as 100% tracking. It means: every dollar spent, every asset shipped, and every donation received is tied by at least one identifier to a source. Where a donor opts out of ad-platform tracking, we still keep first-party attribution (UTM parameters, session-cookie, and the donor record) — we simply do not send that event to Meta/Google/LinkedIn. There is no "unattributed" row in the operating dashboard.
Three concrete rules:
source = direct/unknown in the CRM — not "leave blank". Blanks accumulate silently; a named "unknown" bucket doesn't.marketing_consent, ad_platform_consent, and consent_timestamp. Ad-platform events only fire where ad_platform_consent = true. First-party attribution still records for internal reporting under legitimate interest under the Privacy Act 1988 (see §6).Every paid ad, every organic post, every partner link, and every internal cross-link that could be measured uses the taxonomy below. Free-text values are the single biggest source of dashboard breakage — the controlled vocabulary prevents it.
| Parameter | Purpose | Controlled vocabulary (values) | Example |
|---|---|---|---|
utm_source |
Which platform / property the visitor came from | meta, google, linkedin, youtube, bing, email, partner, organic-social, referrer-site, direct, give-hub, calculator, christmas-appeal, foundation-pack, referrer-pack, pr, qr-print |
utm_source=meta |
utm_medium |
The type of asset that carried the visitor here | paid-social, paid-search, display, video, email-nurture, email-appeal, organic-social, hub-card, calc-cta, ad-pack, partner-newsletter, editorial, qr-code |
utm_medium=paid-social |
utm_campaign |
Campaign identifier — the reporting rollup | Format: <room-code>-<short-name>-<YYYYMM>. Example rooms: rc (Room C), ra, rb, rf. Example short-names: christmas, launch, eofy, calc, anchor |
utm_campaign=rc-christmas-202612 |
utm_content |
Creative or placement variant — the A/B slot | Free-form, kebab-case. Should identify the specific creative asset. Example: video-15s-margaret, still-3-carer-hands, headline-b-19-25-anchor, carousel-4slides, hub-card-weekly-featured |
utm_content=video-15s-margaret |
utm_term |
Search-only: keyword or match-type | Google Ads: use auto-populated {keyword} and {matchtype} tokens. Meta: leave blank (Meta doesn't use search terms). |
utm_term={keyword} |
Naming discipline. Lowercase, kebab-case, no spaces, no special characters, no year-suffix in utm_source or utm_medium (year lives in utm_campaign). Any URL that includes a UTM parameter with a value not in the controlled vocabulary is rejected by the pre-ad-launch QA check (see §7). This is enforced upstream so the dashboard never breaks downstream.
| Sprint | Campaign short-name | Full utm_campaign | Live window |
|---|---|---|---|
| 48 | rc-calc | rc-calc-202610 | 25 Oct 2026 → ongoing |
| 48 | rc-anchor | rc-anchor-202611 | 1 Nov 2026 → first ad test |
| 49 | rc-christmas | rc-christmas-202612 | 28 Nov → 24 Dec 2026 |
| 49 | rc-gt (Giving Tuesday) | rc-gt-20261202 | 2 Dec 2026 only |
Events fire client-side to the first-party datalayer, then server-side to ad platforms (where consent permits). Each event has a fixed name, a fixed payload schema, and a fixed trigger. The dev team implements exactly these — no ad-hoc additions.
| # | Event name | Trigger | Required fields | Server-side destination |
|---|---|---|---|---|
| 1 | page_view | Every /give, /donate, /weekly-giving-calculator, /christmas-appeal page load | path, title, utm_* (5), session_id, consent_flags | Meta CAPI PageView, GA4 page_view |
| 2 | calc_interact | User changes calculator amount (slider, input, preset) | weekly_amount, method (slider/input/preset), session_id | GA4 only (soft signal, no ad-platform event) |
| 3 | give_hub_path_click | User clicks one of the 5 hub cards on /give | path_selected (weekly/once/corporate/memoriam/bequest), session_id | GA4, Meta CAPI Lead (light) |
| 4 | donate_intent | User arrives on /donate (any query params) | plan, amount_prefill, utm_*, session_id | Meta CAPI Lead, Google Enhanced Conversions |
| 5 | donate_amount_selected | User picks an amount on the /donate page | amount, currency=AUD, frequency, session_id | Meta CAPI AddToCart, GA4 |
| 6 | donate_details_started | User focuses first field in the donor-details form | amount, frequency, session_id | Meta CAPI InitiateCheckout, GA4 |
| 7 | donate_details_completed | Donor details form validates and submits | amount, frequency, hashed_email, hashed_phone, postcode, session_id | Meta CAPI, Google Enhanced Conversions, LinkedIn CAPI |
| 8 | donate_payment_started | User lands on payment page (Stripe or equivalent) | amount, frequency, session_id | Meta CAPI, GA4 |
| 9 | donate_success (canonical conversion) | Server confirms successful payment — fires ONLY from server webhook, NOT client | amount, currency=AUD, frequency, hashed_email, hashed_phone, utm_* (all 5), session_id, donor_id, consent_flags, event_id (dedup) | Meta CAPI Purchase, Google Enhanced Conversions, LinkedIn CAPI, GA4 — the canonical revenue event |
| 10 | donate_failed | Server confirms payment failure | failure_code, amount, session_id | GA4 only (internal quality signal) |
| 11 | thank_you_view | Donor lands on /thank-you | plan, amount, session_id, donor_id | GA4 |
| 12 | receipt_sent | Server confirms email receipt sent (SLA target: ≤ 4 hours) | donor_id, latency_seconds, email_provider_message_id | Internal ops dashboard only (SLA monitoring) |
donate_success (JSON schema, for dev){
"event_name": "donate_success",
"event_id": "lwd_1699324800_a3f92e", // unique per gift; used for CAPI dedup
"event_time": 1699324800, // unix seconds, UTC
"currency": "AUD",
"amount": 19.25,
"frequency": "weekly", // weekly | once | monthly | in-memoriam
"donor_id": "lwd_donor_00417", // LWD-internal ID
"identity": {
"hashed_email": "<sha256(lowercase(email))>",
"hashed_phone": "<sha256(e164_phone)>",
"postcode": "2500",
"country": "au"
},
"attribution": {
"utm_source": "meta",
"utm_medium": "paid-social",
"utm_campaign": "rc-christmas-202612",
"utm_content": "video-15s-margaret",
"utm_term": null,
"referrer": "https://www.facebook.com/",
"first_touch_utm": "meta / paid-social / rc-anchor-202611",
"session_id": "sess_a3f92e14b8"
},
"consent": {
"marketing_consent": true,
"ad_platform_consent": true,
"consent_timestamp": 1699324700
},
"platform_ids": { // captured browser-side, passed to server
"fbp": "fb.1.1699320000000.1234567890",
"fbc": "fb.1.1699320000000.IwAR3xyz",
"gclid": "Cj0KCQi...",
"li_fat_id": "..."
}
}
The dev team implements one first-party cookie plus one server-side session record. Nothing else. No third-party pixels, no cross-site cookies.
lwd_session| Attribute | Value / rule |
|---|---|
Name | lwd_session |
Domain | .cosailifewithoutdebt.org |
Path | / |
Secure | true (HTTPS only) |
HttpOnly | false (client-side JS reads it for datalayer) |
SameSite | Lax |
Max-Age | 30 days (2,592,000 seconds) |
Value | Opaque session ID, e.g. sess_a3f92e14b8. No PII in the cookie value itself. |
marketing_sessions table)CREATE TABLE marketing_sessions (
session_id TEXT PRIMARY KEY, -- matches lwd_session cookie
first_seen_ts INTEGER NOT NULL, -- unix seconds
last_seen_ts INTEGER NOT NULL,
first_touch_utm TEXT, -- pipe-delim: src|med|cmp|cnt|trm
last_touch_utm TEXT,
first_referrer TEXT,
first_landing TEXT, -- e.g. /give?utm_source=meta&...
fbp TEXT, -- Meta browser ID
fbc TEXT, -- Meta click ID
gclid TEXT, -- Google click ID
li_fat_id TEXT, -- LinkedIn ID
consent_flags TEXT -- JSON: {"marketing":true,"ad":false,...}
);
CREATE INDEX idx_ms_last ON marketing_sessions (last_seen_ts);
What we do NOT store. No raw email, no raw phone, no name, no address in the session record. Those live on the donors table which is only written on successful donation. The session record is deliberately PII-free so its retention rules can be shorter than the donor record.
All three ad-platform integrations run server-side from the /donate confirmation webhook. Not from the browser. This is the single most-important architectural decision for post-iOS-14 attribution durability.
| Config | Value / notes |
|---|---|
| Pixel ID | To be created on ad-account setup — store in MARKETING_META_PIXEL_ID environment variable (Cloudflare Workers secret). |
| Access token | Long-lived system-user token from Meta Business Manager. Store in MARKETING_META_CAPI_TOKEN secret. Never in code. |
| Endpoint | https://graph.facebook.com/v18.0/<PIXEL_ID>/events |
| Events sent | PageView, Lead, AddToCart, InitiateCheckout, Purchase — mapped from event dictionary §3 |
| Dedup key | event_id matches the browser-side pixel eventID when both fire — Meta dedups automatically |
| PII hashing | SHA-256, lowercase, trimmed. Email: lowercased. Phone: E.164 format without +. Postcode: raw (already anonymised enough). |
| Retry policy | 3 retries with exponential back-off (2s, 8s, 30s). After 3 failures, write to failed_capi_events table for manual retry. |
| Config | Value / notes |
|---|---|
| Measurement ID | To be created — store in MARKETING_GA4_MEASUREMENT_ID secret. |
| API secret | GA4 Measurement Protocol secret — store in MARKETING_GA4_API_SECRET. |
| Endpoint | https://www.google-analytics.com/mp/collect?measurement_id=<ID>&api_secret=<SECRET> |
| Enhanced Conversion linkage | Google Ads linked to GA4 in the Google Ads UI. Enhanced Conversions accepts hashed email/phone; delivered via the GA4 event payload user_data field. |
| Events sent | page_view, generate_lead, add_to_cart, begin_checkout, purchase |
| Config | Value / notes |
|---|---|
| Purpose | Room B (Corporate) attribution only. Not fired from Room C donor traffic. |
| When to activate | Sprint 50 (LinkedIn thought-leadership warm-up). Not required for 25 Oct deadline. |
| Config placeholder | Environment variable slots reserved: MARKETING_LI_ACCOUNT_ID, MARKETING_LI_CAPI_TOKEN, MARKETING_LI_CONVERSION_ID. Left unset until Sprint 50. |
Two-tier consent model. Australian privacy law and marketing best practice require distinct consent for (a) LWD storing and processing personal information, and (b) LWD sharing hashed identifiers with third-party ad platforms. LWD collects both, records both, and honours both independently.
| Consent flag | Default | Wording shown to donor at capture |
|---|---|---|
marketing_consent | Unticked | “I'd like Life Without Debt to email me occasional impact updates and stewardship communications. I can unsubscribe at any time.” |
ad_platform_consent | Unticked | “I consent to Life Without Debt sharing my hashed email with Meta, Google, and LinkedIn to measure the effectiveness of our advertising. No advertiser sees my raw personal details. This is separate from receiving marketing from LWD directly.” |
Consent discipline enforced in code. The /donate confirmation handler checks consent.ad_platform_consent === true before including the identity block in any Meta / Google / LinkedIn CAPI event. If consent is false, the event still fires (so we retain internal attribution), but the identity object is stripped — only the anonymous session_id and platform_ids (fbp/fbc/gclid) go to the platform. If session cookies are also blocked, the event does not fire at all.
Attribution isn't only a data topic. Every ad, every UTM-tagged landing page, and every campaign email is subject to ASIC Regulatory Guide 96. Approved outcome language:
Every UTM-parameterised campaign passes the compliance audit engine (see /marketing-governance) before its first ad-spend dollar is spent. Zero findings, or the ad does not go live.
Each row is a pass/fail test. All 24 must pass before Carla signs off for ad launch. Test log lives in the intranet at /intranet/attribution-qa-log (to be created).
| # | Test | Pass criteria | Owner |
|---|---|---|---|
| 1 | UTM-decorated URL round-trip | URL /give?utm_source=meta&utm_medium=paid-social&utm_campaign=rc-test-202610 lands, all 3 params visible in session record | Web-dev |
| 2 | UTM controlled-vocabulary check | Invalid utm_source=Meta (capitalised) rejected by pre-publish lint | Web-dev |
| 3 | Session cookie set | lwd_session cookie set on first visit, persists on second visit, expires at 30 days | Web-dev |
| 4 | First-touch UTM preserved | Visit A (meta), visit B (direct), donation in visit B — first_touch_utm = meta on donor record | Web-dev |
| 5 | Last-touch UTM captured | Same visit B example — last_touch_utm = direct on donor record | Web-dev |
| 6 | Calculator event fires | calc_interact fires on slider change and on preset click, GA4 dashboard shows event within 30 minutes | Web-dev |
| 7 | Give-hub click event fires | Clicking any of 5 /give hub cards fires give_hub_path_click with correct path_selected | Web-dev |
| 8 | donate_success fires server-side | Test gift of $19.25/wk fires donate_success from server webhook, not browser | Web-dev |
| 9 | Meta CAPI Purchase received | Meta Events Manager “Test Events” tab shows Purchase event within 60 seconds | Web-dev |
| 10 | Meta CAPI dedup working | Browser pixel and server CAPI both fire — Meta shows 1 event, not 2, based on event_id | Web-dev |
| 11 | Google Enhanced Conversions received | GA4 Realtime shows purchase event within 30 seconds; Google Ads Conversion tracking shows within 24 hours | Web-dev |
| 12 | Hashed email format valid | SHA-256 of test@example.com lowercased matches known value: 973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b | Web-dev |
| 13 | Consent flag: ad platform false | Donor with ad_platform_consent=false — server event fires without identity block | Web-dev |
| 14 | Consent flag: marketing false | Donor with marketing_consent=false — not enrolled in email nurture sequence | CRM ops |
| 15 | Receipt email sent < 4h | Test gift — receipt email arrives within 4 hours (SLA); receipt_sent event logged with latency_seconds | CRM ops |
| 16 | Thank-you page loads | Post-payment redirect to /thank-you resolves in < 2 seconds, WCAG 2.2 AA | Web-dev |
| 17 | Ad-blocker fallback | Test with uBlock Origin enabled — browser pixel blocked, server CAPI still fires successfully | Web-dev |
| 18 | iOS Safari ITP resilience | Test on iOS 17 Safari — first-party cookie persists, third-party trackers blocked, first-touch UTM survives | Web-dev |
| 19 | Compliance footer present | ABN, ACN, ACL 387398, complaints link, privacy link all server-rendered on /give, /donate, /weekly-giving-calculator, /thank-you | AI CMO |
| 20 | RG 96 language check | Grep against controlled banned-phrase list — zero hits for “eliminate debt”, “wipe out debt”, “debt free guaranteed”, “we reduce your debt” | AI CMO |
| 21 | Audit engine clean | New pages pass the compliance audit at /api/compliance-audit/run with 0 findings | AI CMO |
| 22 | Attribution dashboard populated | Test 5 gifts across 5 different UTM-sources — dashboard shows all 5 correctly attributed within 1 hour | AI CMO |
| 23 | Manual-source assignment works | Direct/unknown gift — operator can set manual_source field in CRM; shows in dashboard under “direct/unknown” bucket | CRM ops |
| 24 | Full-funnel report renders | Report at /intranet/attribution-report shows PageView → Lead → Purchase counts per utm_source for the test window | AI CMO |
Three dashboards, no more. Every operator question about “where did that gift come from” is answered by one of them.
-- Runs on the Cloudflare D1 donors + marketing_sessions tables
SELECT
d.utm_source,
d.utm_medium,
d.utm_campaign,
COUNT(*) AS gifts,
SUM(d.amount) AS gross_aud,
AVG(d.amount) AS avg_gift_aud,
SUM(CASE WHEN d.frequency='weekly' THEN d.amount*52 ELSE d.amount END) AS annualised_aud
FROM donors d
WHERE d.first_gift_ts BETWEEN :start AND :end
GROUP BY d.utm_source, d.utm_medium, d.utm_campaign
ORDER BY annualised_aud DESC;
Rows: utm_source. Columns: PageView → Lead → AddToCart → Purchase → conversion-rate. Purpose: identify which channels bring traffic that donates versus traffic that bounces.
Rows: last 7 days of gifts. Columns: gift time → receipt-sent time → latency → SLA-status (green ≤ 4h, amber 4–8h, red > 8h) → failed CAPI events. Purpose: catch the day the receipt SLA slips, or the day a CAPI endpoint goes down, before it becomes a week of missing attribution.
The site currently has none of this. Cut-over sequence:
marketing_sessions and failed_capi_events created and migrated. Cookie schema deployed. First-party attribution live — ad-platform integration still off.rc-anchor-202611 campaign. Attribution watched hourly for 48 hours.What we do if we slip. If any of the 24 QA tests fails on 20 Oct, Carla is notified same-day. The go/no-go decision on 22 Oct EOD is binary: (a) ship reduced scope — e.g., manual attribution only, no ad-platform CAPI — and delay ad launch to 1 Nov, or (b) ship on 25 Oct with 2 Nov as the “fix any residual” date. Christmas Appeal launch (2 Dec) is the hard deadline — nothing slips past it.