Sprint 48 · Room C conversion infrastructure

Attribution implementation spec

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.

Contents

  1. Non-negotiables — what “100% attribution” means operationally
  2. UTM taxonomy — 5 parameters, controlled vocabulary
  3. Event dictionary — 12 tracked events with full payload spec
  4. First-party identity capture — cookie schema + storage
  5. Server-side integration — Meta CAPI, Google Enhanced Conversions, LinkedIn CAPI
  6. Consent, privacy, and RG 96 discipline
  7. QA test matrix — 24 tests to pass before 25 Oct launch
  8. Operator dashboards — 3 canonical views
  9. Migration and cut-over plan (from “no attribution” to “100%”)

1. Non-negotiables — what “100% attribution” means operationally

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:

  1. No donation without a source. If a gift arrives with no UTM, no referrer, and no in-conversation source note, the operator manually assigns source = direct/unknown in the CRM — not "leave blank". Blanks accumulate silently; a named "unknown" bucket doesn't.
  2. Server-side is the source of truth. Browser pixel events are a nice-to-have. The system-of-record for attribution is the server-side event that fires from the /donate confirmation endpoint. Ad blockers, iOS ITP, and third-party-cookie deprecation do not affect first-party attribution because we don't rely on it.
  3. Consent is a first-class field. Every donor row carries 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).

2. UTM taxonomy — 5 parameters, controlled vocabulary

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.

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

Campaign naming reference (Room C, Aug–Dec 2026)

SprintCampaign short-nameFull utm_campaignLive window
48rc-calcrc-calc-20261025 Oct 2026 → ongoing
48rc-anchorrc-anchor-2026111 Nov 2026 → first ad test
49rc-christmasrc-christmas-20261228 Nov → 24 Dec 2026
49rc-gt (Giving Tuesday)rc-gt-202612022 Dec 2026 only

3. Event dictionary — 12 tracked events with full payload spec

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 nameTriggerRequired fieldsServer-side destination
1page_viewEvery /give, /donate, /weekly-giving-calculator, /christmas-appeal page loadpath, title, utm_* (5), session_id, consent_flagsMeta CAPI PageView, GA4 page_view
2calc_interactUser changes calculator amount (slider, input, preset)weekly_amount, method (slider/input/preset), session_idGA4 only (soft signal, no ad-platform event)
3give_hub_path_clickUser clicks one of the 5 hub cards on /givepath_selected (weekly/once/corporate/memoriam/bequest), session_idGA4, Meta CAPI Lead (light)
4donate_intentUser arrives on /donate (any query params)plan, amount_prefill, utm_*, session_idMeta CAPI Lead, Google Enhanced Conversions
5donate_amount_selectedUser picks an amount on the /donate pageamount, currency=AUD, frequency, session_idMeta CAPI AddToCart, GA4
6donate_details_startedUser focuses first field in the donor-details formamount, frequency, session_idMeta CAPI InitiateCheckout, GA4
7donate_details_completedDonor details form validates and submitsamount, frequency, hashed_email, hashed_phone, postcode, session_idMeta CAPI, Google Enhanced Conversions, LinkedIn CAPI
8donate_payment_startedUser lands on payment page (Stripe or equivalent)amount, frequency, session_idMeta CAPI, GA4
9donate_success (canonical conversion)Server confirms successful payment — fires ONLY from server webhook, NOT clientamount, 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
10donate_failedServer confirms payment failurefailure_code, amount, session_idGA4 only (internal quality signal)
11thank_you_viewDonor lands on /thank-youplan, amount, session_id, donor_idGA4
12receipt_sentServer confirms email receipt sent (SLA target: ≤ 4 hours)donor_id, latency_seconds, email_provider_message_idInternal ops dashboard only (SLA monitoring)

Canonical payload — 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": "..."
  }
}

4. First-party identity capture — cookie schema + storage

The dev team implements one first-party cookie plus one server-side session record. Nothing else. No third-party pixels, no cross-site cookies.

Cookie: lwd_session

AttributeValue / rule
Namelwd_session
Domain.cosailifewithoutdebt.org
Path/
Securetrue (HTTPS only)
HttpOnlyfalse (client-side JS reads it for datalayer)
SameSiteLax
Max-Age30 days (2,592,000 seconds)
ValueOpaque session ID, e.g. sess_a3f92e14b8. No PII in the cookie value itself.

Server-side session record (Cloudflare D1 — 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.

5. Server-side integration — Meta CAPI, Google Enhanced Conversions, LinkedIn CAPI

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.

Meta Conversions API (CAPI)

ConfigValue / notes
Pixel IDTo be created on ad-account setup — store in MARKETING_META_PIXEL_ID environment variable (Cloudflare Workers secret).
Access tokenLong-lived system-user token from Meta Business Manager. Store in MARKETING_META_CAPI_TOKEN secret. Never in code.
Endpointhttps://graph.facebook.com/v18.0/<PIXEL_ID>/events
Events sentPageView, Lead, AddToCart, InitiateCheckout, Purchase — mapped from event dictionary §3
Dedup keyevent_id matches the browser-side pixel eventID when both fire — Meta dedups automatically
PII hashingSHA-256, lowercase, trimmed. Email: lowercased. Phone: E.164 format without +. Postcode: raw (already anonymised enough).
Retry policy3 retries with exponential back-off (2s, 8s, 30s). After 3 failures, write to failed_capi_events table for manual retry.

Google Enhanced Conversions (via GA4 + Google Ads)

ConfigValue / notes
Measurement IDTo be created — store in MARKETING_GA4_MEASUREMENT_ID secret.
API secretGA4 Measurement Protocol secret — store in MARKETING_GA4_API_SECRET.
Endpointhttps://www.google-analytics.com/mp/collect?measurement_id=<ID>&api_secret=<SECRET>
Enhanced Conversion linkageGoogle 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 sentpage_view, generate_lead, add_to_cart, begin_checkout, purchase

LinkedIn Conversions API

ConfigValue / notes
PurposeRoom B (Corporate) attribution only. Not fired from Room C donor traffic.
When to activateSprint 50 (LinkedIn thought-leadership warm-up). Not required for 25 Oct deadline.
Config placeholderEnvironment variable slots reserved: MARKETING_LI_ACCOUNT_ID, MARKETING_LI_CAPI_TOKEN, MARKETING_LI_CONVERSION_ID. Left unset until Sprint 50.

6. Consent, privacy, and RG 96 discipline

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 capture on /donate

Consent flagDefaultWording shown to donor at capture
marketing_consentUnticked“I'd like Life Without Debt to email me occasional impact updates and stewardship communications. I can unsubscribe at any time.”
ad_platform_consentUnticked“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.

RG 96 — language discipline in ad copy and landing pages

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.

7. QA test matrix — 24 tests to pass before 25 Oct launch

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

#TestPass criteriaOwner
1UTM-decorated URL round-tripURL /give?utm_source=meta&utm_medium=paid-social&utm_campaign=rc-test-202610 lands, all 3 params visible in session recordWeb-dev
2UTM controlled-vocabulary checkInvalid utm_source=Meta (capitalised) rejected by pre-publish lintWeb-dev
3Session cookie setlwd_session cookie set on first visit, persists on second visit, expires at 30 daysWeb-dev
4First-touch UTM preservedVisit A (meta), visit B (direct), donation in visit B — first_touch_utm = meta on donor recordWeb-dev
5Last-touch UTM capturedSame visit B example — last_touch_utm = direct on donor recordWeb-dev
6Calculator event firescalc_interact fires on slider change and on preset click, GA4 dashboard shows event within 30 minutesWeb-dev
7Give-hub click event firesClicking any of 5 /give hub cards fires give_hub_path_click with correct path_selectedWeb-dev
8donate_success fires server-sideTest gift of $19.25/wk fires donate_success from server webhook, not browserWeb-dev
9Meta CAPI Purchase receivedMeta Events Manager “Test Events” tab shows Purchase event within 60 secondsWeb-dev
10Meta CAPI dedup workingBrowser pixel and server CAPI both fire — Meta shows 1 event, not 2, based on event_idWeb-dev
11Google Enhanced Conversions receivedGA4 Realtime shows purchase event within 30 seconds; Google Ads Conversion tracking shows within 24 hoursWeb-dev
12Hashed email format validSHA-256 of [email protected] lowercased matches known value: 973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813bWeb-dev
13Consent flag: ad platform falseDonor with ad_platform_consent=false — server event fires without identity blockWeb-dev
14Consent flag: marketing falseDonor with marketing_consent=false — not enrolled in email nurture sequenceCRM ops
15Receipt email sent < 4hTest gift — receipt email arrives within 4 hours (SLA); receipt_sent event logged with latency_secondsCRM ops
16Thank-you page loadsPost-payment redirect to /thank-you resolves in < 2 seconds, WCAG 2.2 AAWeb-dev
17Ad-blocker fallbackTest with uBlock Origin enabled — browser pixel blocked, server CAPI still fires successfullyWeb-dev
18iOS Safari ITP resilienceTest on iOS 17 Safari — first-party cookie persists, third-party trackers blocked, first-touch UTM survivesWeb-dev
19Compliance footer presentABN, ACN, ACL 387398, complaints link, privacy link all server-rendered on /give, /donate, /weekly-giving-calculator, /thank-youAI CMO
20RG 96 language checkGrep against controlled banned-phrase list — zero hits for “eliminate debt”, “wipe out debt”, “debt free guaranteed”, “we reduce your debt”AI CMO
21Audit engine cleanNew pages pass the compliance audit at /api/compliance-audit/run with 0 findingsAI CMO
22Attribution dashboard populatedTest 5 gifts across 5 different UTM-sources — dashboard shows all 5 correctly attributed within 1 hourAI CMO
23Manual-source assignment worksDirect/unknown gift — operator can set manual_source field in CRM; shows in dashboard under “direct/unknown” bucketCRM ops
24Full-funnel report rendersReport at /intranet/attribution-report shows PageView → Lead → Purchase counts per utm_source for the test windowAI CMO

8. Operator dashboards — 3 canonical views

Three dashboards, no more. Every operator question about “where did that gift come from” is answered by one of them.

Dashboard 1 — Source-of-truth revenue table

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

Dashboard 2 — Full-funnel by source

Rows: utm_source. Columns: PageView → Lead → AddToCart → Purchase → conversion-rate. Purpose: identify which channels bring traffic that donates versus traffic that bounces.

Dashboard 3 — SLA and quality monitor

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.

9. Migration and cut-over plan (from “no attribution” to “100%”)

The site currently has none of this. Cut-over sequence:

  1. Week of 22 Sep (T-33 days): Meta Business Manager account created, pixel provisioned, CAPI system-user token generated. GA4 property created and linked to Google Ads. All secrets stored in Cloudflare Workers.
  2. Week of 29 Sep (T-26): D1 tables marketing_sessions and failed_capi_events created and migrated. Cookie schema deployed. First-party attribution live — ad-platform integration still off.
  3. Week of 6 Oct (T-19): Event dictionary implemented, all 12 events firing to GA4. Meta CAPI wired in test mode (Meta “Test Events” only, not live pixel).
  4. Week of 13 Oct (T-12): Consent capture UI shipped on /donate. Two-tier consent flags flowing into D1. RG 96 language grep gate live in CI.
  5. Week of 20 Oct (T-5): Full QA test matrix executed. 24 tests, target 24 passes. Any fail = go/no-go escalation to Carla by 22 Oct EOD.
  6. 25 Oct 2026 (T-0): Ad-launch readiness declared. First $500 Meta test spend on rc-anchor-202611 campaign. Attribution watched hourly for 48 hours.
  7. 1 Nov 2026: Christmas Appeal build begins on top of a working attribution stack (Sprint 49).

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.