The fastest way to turn a compliance asset into a liability is to honor consent in the browser and forget about it everywhere else. Server-side attribution moves your conversions out of the browser — so your consent signal has to make the same trip.
Consent propagation is the fourth of the five properties of a working server-side conversion attribution stack, and it is the one most likely to be bolted on last and break first. The fix is a single canonical consent-state object that travels with every event — browser to CRM to Worker to each platform's documented field — translated per platform at the Worker boundary, not at the cookie banner. Here is the object, the per-platform translations, the CRM-side flag that catches withdrawals, and the six ways it breaks.
Why it is non-negotiable
Compliance. GDPR, CCPA, and the wave of US state privacy laws treat sending hashed PII to a conversion API as processing personal data. Firing the event for a user who withheld consent is a breach with enforceable penalties.
Trust. A user who clicks "reject" and then watches their data flow to ad platforms anyway treats you as untrustworthy — the unmeasurable variable the consent layer protects.
Bidder signal. The platforms now suppress non-consented signal themselves. Google's Consent Mode v2 reads ad_user_data: denied and excludes those events from optimization. Firing without consent does not just risk compliance — it degrades the optimization surface for the consented events arriving alongside.
The canonical consent-state object
One object, defined once on the canonical event schema, traveling with every event:
type ConsentState = {
ad_storage: 'granted' | 'denied' // marketing/ads cookies
analytics_storage: 'granted' | 'denied' // analytics cookies
ad_user_data: 'granted' | 'denied' // sharing user data with ad partners
ad_personalization: 'granted' | 'denied' // personalized advertising
region: string // ISO 3166-2 (us-ca, eu-de, …)
consent_collected_at: number // Unix seconds
consent_method: 'banner' | 'preference_center' | 'implicit' | 'pre_consent'
consent_record_id: string // CMP-side audit primitive
}
Four of those fields are Google's Consent Mode v2 vocabulary; mapping every banner's output to them at the canonical boundary lets every downstream translation use the same four-signal frame. region carries the ISO 3166-2 subdivision so the Worker can apply California-specific gates like Meta's Limited Data Use. consent_collected_at stamps the decision, and pre-consent traffic (the window between page load and decision) is flagged pre_consent and never fires events. consent_record_id is the audit chain — event_id → consent_record_id → CMP record → user action — the difference between proving a banner exists and proving a specific user consented.
Google Consent Mode v2 — two signals on every request
Mandatory for EEA/UK/Switzerland traffic since March 2024 and widely adopted globally, Consent Mode v2 carries two of its four signals directly in the server-side request (the other two are browser cookie-storage signals that do not travel server-side):
function canonicalToGoogleConsent(c: ConsentState) {
return {
ad_user_data: c.ad_user_data === 'granted' ? 'GRANTED' : 'DENIED',
ad_personalization: c.ad_personalization === 'granted' ? 'GRANTED' : 'DENIED',
}
}
A DENIED value does not reject the upload — Google still receives the conversion for aggregate reporting but excludes it from personalized bidding and audience expansion. The count survives; the per-user attribution drops.
Meta Limited Data Use — gated on California + opt-out
Meta's Limited Data Use is the CCPA-specific path. The Conversions API request carries a data_processing_options array plus country and state codes, and the gate has to be precise — firing LDU on every event throttles every conversion's signal, including for users outside California:
function canonicalToMetaLDU(c: ConsentState) {
const isCalifornia = c.region?.startsWith('us-ca')
const optedOut = c.ad_user_data === 'denied' || c.ad_personalization === 'denied'
return isCalifornia && optedOut
? { data_processing_options: ['LDU'], data_processing_options_country: 1, data_processing_options_state: 1000 }
: { data_processing_options: [] }
}
(country: 1 is the US; state: 1000 is California.) LDU is effective per-event, not per-pixel — the same pixel fires LDU and non-LDU events and Meta handles the distinction.
Microsoft, TikTok, GA4 — the remaining three
Each has a consent-equivalent field, mapped from the same canonical object. Microsoft Ads takes a consent_mode object carrying the four Google-equivalent signals directly. GA4's Measurement Protocol takes a consent object with ad_user_data and ad_personalization — honoring denied by excluding the event from Google Ads conversion linking and Google Signals. TikTok's Events API is the odd one out, taking a processing_options array of limitation codes:
function canonicalToTikTokConsent(c: ConsentState) {
const limitations = []
if (c.ad_user_data === 'denied') limitations.push('LDU') // Limited Data Use
if (c.ad_personalization === 'denied') limitations.push('LMU') // Limited Match Use
return { processing_options: limitations }
}
Want consent propagated correctly the first time? Talk to the team that runs it under audit. →
The CRM-side consent flag
The subtle failure: consent diverging between the browser and the CRM. A user grants at the banner, the CRM record is created, then the user visits a preference center and withdraws — and the Worker keeps firing, because it read consent from the browser at event time and the browser is no longer the source of truth. The fix is a CRM-side consent_state field on the contact, updated by the CMP, and the Worker treating a CRM-side withdrawal as authoritative. In HubSpot this is the native marketing_consent_status plus a custom JSON property carrying the four-signal vocabulary; in Salesforce, HasOptedOutOfEmail plus a custom Consent_State_Object__c. The webhook fires on contact updates; the Worker reads both and short-circuits the fanout if either signals denial.
The six failure modes
- Defaulting to grant on missing fields. An
undefinedsignal should resolve todenied, notgranted. Default to denied. - Ignoring CRM-side withdrawal. The freshest consent for a returning user lives on the CRM record, not the browser.
- Firing during the pre-consent window. The 100–500ms before a decision is made — short-circuit on the
pre_consentflag. - Applying LDU indiscriminately. Gate it on California residency plus opt-out, not as a blanket safety position.
- Stale
consent_collected_aton re-consent. A user who denies, returns, and grants must get a fresh timestamp, or the audit fails. - Logging consent alongside PII. Pairing the consent record ID with hashed identifiers in logs creates a derived identifier. Treat the consent layer with the same logging discipline as PII.
Closing
Consent propagation is the most subtle of the five properties because the patterns themselves are simple — the platform fields are stable, the translation functions are pure. What makes it hard is the coordination across the banner, the CRM, the Worker, and the platform APIs, each with its own representation of consent and its own update cadence. Get the canonical object right and every downstream translation falls out as a pure function. Skip it and every integration grows its own consent logic and drifts. The boring stack that prints keeps consent in one place and translates at the edge.
Ready to ship consent propagation under audit-grade discipline? Book a 30-minute call →
Get the kit, not just the theory.
We'll send the build checklist behind this post — and the next pillar when it ships. One email, no drip sequence. Unsubscribe in one click.