Skip to content

Attribution Capture

Edition 3.1.1

Consent dependency: This section describes the full capability, and every part of it waits on consent. While consent is pending (the visitor has not answered), the core may hold only the closed three-value campaign context (source, medium, campaign) in current-page memory. It writes nothing to device storage and sends nothing. After pending, each stage needs its own basis under the effective permissions or an established not_applicable site policy: reading the incoming request, saving data in the browser, sending it to the endpoint, and forwarding it onward. See Consent Integration.


Attribution data degrades the moment it leaves the first HTTP request. Native capture means your own server reads the URL and referrer on the landing request, before any script loads and before blockers can interfere. Three reasons:

  1. Safari’s decorated-navigation rule. When a visitor arrives from a domain Safari classifies as a tracker, and the landing URL carries any query string or fragment, Safari caps every cookie set via document.cookie on that page to 24 hours. A cookie your server sets from first-party-aligned serving is outside that class.
  2. Ad blockers block the scripts that would read the URL. If a tag never loads, its capture never happens. Your own application code runs with the page.
  3. Navigation loses the query string. Reading the parameters on the landing request beats reading them later, after a route change may have discarded them.

Native capture improves resilience; it is not unblockable and guarantees no fixed cookie lifetime.


ParameterPurposeExample
utm_sourceTraffic sourcegoogle, facebook, newsletter
utm_mediumMarketing mediumcpc, email, social
utm_campaignCampaign namespring_sale_2026
utm_termPaid search keywordrunning+shoes
utm_contentAd variationad_v2, cta_red

UTM is a parameter name, not a GDPR classification. Campaign-level UTM values are not automatically personal data; classification is contextual.

Click identifiers (gclid, fbclid, and the rest) are tokens ad platforms add to outbound links so a click can be matched to a conversion later. All click identifiers are treated as online identifiers, personal data in context (GDPR Art. 4(1), Recital 30). That includes gbraid/wbraid: conservatively treated as online identifiers in context rather than categorically “aggregate.”

ParameterPlatformHandling notes (verified 2026-07-19; evidence class stated per claim — primary-sourced unless a row marks it community-observed, unofficial, or an inference)
gclidGoogle AdsFormat is officially opaque — store verbatim, never parse; observed lengths ~30–100+ chars (community-observed). Google Ads offline import supports click-to-conversion cycles up to 90 days for standard Data Manager sources (14 days for some database sources). Since 2026-06-15 the legacy Ads API rejects offline-conversion-import and enhanced-conversions-for-leads uploads from all integrators — the Data Manager API is the universal upload path (developer tokens inactive January–June 2026 lost legacy allow-list access; vendor policy, verified 2026-08-07). Since April 2026, enhanced conversions for web and for leads are merged into a single account-level on/off setting, accepting user-provided data from website tags, Data Manager, and API connections simultaneously (verified 2026-08-07)
gbraid / wbraidGoogle Ads (iOS)Opaque; treated as identifiers in context
dclidCM360/DV360CM360 documents click ≤60 days old AND conversion timestamp ≤28 days old (CM360 Conversions FAQ, 2026-04-20)
fbclidMetaMeta CAPI rejects the entire request if any event_time is >7 days old (Meta developer docs). Claims of a 90-day _fbc flag are undocumented
msclkidMicrosoft AdsUUID-like hex; offline import accepts conversions ≤90 days old per Microsoft’s recommendation, but the UET CAPI rejects an eventTime older than 7 days (validation error) — near-real-time forwarding required (verified 2026-08-07)
ttclidTikTokAttribution windows configurable 1, 7, 14, or 28 days (default 7-day click / 1-day view; TikTok Help Centre)
li_fat_idLinkedIn”30 days from most recent ad click” (LinkedIn verbatim). A shorter effective life on Safari is an ITP inference, not LinkedIn documentation
epikPinterestWindow enum {0, 1, 7, 14, 30, 60} days; defaults 30 click / 30 engagement / 1 view (Pinterest API v5). The _epik cookie’s 1-year lifetime is unofficial
twclidX / TwitterOpaque; no official event-age rejection window is documented
ScCidSnapchat28-day click / 1-day view; Conversions API accepts events up to 37 days back (Snap developer docs)

Platform matching windows are not client-retention promises. Retention is governed by the controller-configured ceiling below.

Custom attribution parameters are supported only under this contract:

  • Key syntax ^[a-z0-9_]{1,32}$; reserved names rejected (utm_*, every registered click-ID parameter, uiaf_*, _uiaf_*).
  • Values: scalar strings only; a single URL decode then Unicode NFC; 256 bytes per value; object bounds max 10 properties and max 2,048 total encoded bytes (UTF-8 of the RFC 8785 canonical form). Exceeding any bound fails closed: the parameter (or object) is dropped, never truncated or partially captured. Duplicate query keys: first decoded occurrence wins, later duplicates dropped.
  • Prohibited content: direct identifiers, credentials/tokens, free-form search text, and sensitive/special-category values. Configuring the allowlist requires privacy review; an allowlist prevents accidents, not deliberate misconfiguration.
  • Capture, storage, and transmission are gated by the capability vector like all attribution data, and the endpoint validates independently of client behavior.

Section titled “Pending-Consent Context (pending_context_allowlist)”

While the consent lifecycle is pending (the visitor has not answered the consent prompt), the core may hold at most three values, and only in memory: the campaign source, medium, and name. Everything else (search terms, ad-content variants, the referring site, the landing path, click IDs, and anything user-specific) is excluded. Nothing is written to storage and nothing is sent.


Touch Identity (touch_id) and the Navigation Boundary

Section titled “Touch Identity (touch_id) and the Navigation Boundary”

A touch is one recorded arrival with attribution. A new touch is accepted when the system can tell, from trusted evidence, that the visitor just arrived from outside: the navigation is a real top-level navigation (not a reload or a back/forward restore), and it either carries a new allowlisted attribution signal or provably comes from a different site. Every accepted touch gets a fresh UUID-v4 touch_id, minted at acceptance and preserved across reload, history/BFCache restoration, SPA continuation without a new boundary, and consent enrichment. Storage loss mints a fresh identifier; the client never reconstructs one.

The boundary is checked before any deduplication: it always accepts a new touch and mints a fresh touch_id, even when the campaign values equal the prior touch. Two classified navigations with identical UTM-only content are two touches.

Deduplication uses a configurable window (touch_dedup_window_seconds, default 1,800) and one canonical fingerprint. count counts accepted touch records under this heuristic, not exact human visits or page loads.

The fingerprint object has exactly source, medium, campaign, term, content, referrer, landing_url, click_ids, and custom. Scalar absence or an empty-after-trim scalar is JSON null; click_ids and custom are always maps and serialize empty as {}.

On an ad_storage denied → allowed transition with the same canonical touch otherwise, click IDs are backfilled into the existing touchpoint: count unchanged and is_new_touch: false. Delivery of the enriched state rides the v3 session reason mechanism, not a new touch.


The stored record is {first_touch, last_touch, count}; is_new_touch is a per-load transient carried only in the payload. Each touchpoint:

{
"touch_id": "f325ec79-2dfc-4378-9474-0ad046384e8d",
"source": "google",
"medium": "cpc",
"campaign": "spring_sale",
"term": null,
"content": "ad_v2",
"click_ids": {
"gclid": { "value": "Cj0KCQjw84anBhCtARIsAISI-xfSUJmQ8Z...", "captured_at": 1647291600, "expires_at": 1655067600 }
},
"custom": {},
"referrer": "google.com",
"landing_url": "www.example.com/products/shoes",
"timestamp": 1647291600
}

first_touch is write-once; last_touch updates on each newly accepted touch; count increments per accepted touch record. An absent or non-captured value is an explicit JSON null; no filler like "(direct)" or "unknown" ever stands in for one. Timestamps here are Unix seconds (integers); now() in the pseudocode means the current Unix timestamp in seconds. The payload’s top-level ISO-8601 rule lives in the Endpoint Schema.

  • Every stored click-ID value carries per-value integer metadata 0 <= captured_at < expires_at <= 253402300799. expires_at derives from a finite controller-configured client ceiling, a required configured value with frozen bounds; no universal numeric default exists and none may be inferred from platform matching windows (a missing, non-finite, or out-of-bounds ceiling is a configuration-validation failure with no silent fallback).
  • Bounds: the click-ID map holds at most 16 registered keys; each decoded value is 1–512 UTF-8 bytes; the complete RFC 8785 canonical click-ID object is ≤ 4,096 UTF-8 bytes; each full touchpoint ≤ 6,144 canonical UTF-8 bytes. Oversize input or an over-bound touchpoint is rejected before persistence or body freeze and never truncated.

Every payload also carries the consent state it was produced under, so the endpoint can route on facts rather than assumptions.

Expired values (now >= expires_at) are removed from live stores and omitted during assembly before body freeze. A frozen retry body is different: if any click-ID occurrence in an already-frozen retry body has reached its expires_at, required or optional, wherever carried, the entire queue entry is terminally dropped. Its body, route, required permissions, purpose, destinations, and event_id are never edited, pruned, sanitized, or rerouted; any sanitized emission is an independently authorized new logical event with a new event_id. On ad_storage withdrawal, live stores purge immediately and queued click-ID-bearing entries drop whole under the same rule.


  1. Capture on the first HTTP request, server-side, when permitted. Effective ad_storage == allowed (lifecycle resolved) gates collection; effective analytics_storage == allowed gates persistence. Ads-only states (ad_storage allowed, analytics_storage denied) get purpose-limited in-memory click-ID handling for explicitly consented ad actions, with no persistence.
  2. Store within the attribution record, not separate cookies. Fragmented per-platform cookies multiply expiry and deletion surfaces.
  3. NEVER strip click IDs from the URL. Google Consent Mode detection, platform pixels, and URL-based deduplication read them; capture the value, leave the URL intact.
  4. Never truncate opaque values. Over-limit input is rejected or omitted with explicit data-quality signaling.

When UTMs are present they take precedence. Otherwise the referrer’s registrable domain decides the class: search engines become organic; social domains become social; AI assistants become ai; webmail becomes email; any other cross-site domain becomes referral; empty or same-site is direct.


  • Primary: localStorage. The attribution record as one JSON string under uiaf_attribution, written only under effective analytics_storage == allowed.
  • Session cache: sessionStorage. A copy for fast reads, same key, same gating.

There is no attribution backup cookie. A client-written cookie would add request-header exposure and cookie bloat, complicate purge semantics, and provide no independent Safari resilience (client-written cookies sit in the script-writable class ITP purges). Durability beyond local storage is the endpoint’s concern: every accepted touch is sent promptly in a session payload, and downstream retention is an endpoint/controller policy. Downstream retention does not imply browser restoration: no contract authorizes rehydrating cleared or withdrawn attribution back into the client.

localStorage is authoritative for live attribution; sessionStorage is a validated per-tab cache. A cache read is honored only when it validates against the authoritative localStorage record: a stale per-tab copy never overwrites newer cross-tab state, and a session copy surviving localStorage clearing is not silently promoted. Partial storage loss follows the known-withdrawal and privacy-default rules of Identity Management: no automatic cross-store resurrection. Identity recovery restores identity only; cleared attribution is never resurrected by the identity recovery policy. Write both stores under the predicate; purge both on downgrade per the registry’s transition rows.


// PSEUDOCODE — Adapt to your platform. Authoritative consent is re-read INSIDE
// this function and again immediately before every persistence write and every
// send — a permissions value passed in from earlier is stale and never trusted.
// loadAttribution() always returns a record: {first_touch: null, last_touch:
// null, count: 0, is_new_touch: false} when nothing is stored (never null),
// and the same shell — logging once — on malformed stored JSON.
function captureAttribution(url, navigation):
consent = readAuthoritativeConsent() // CMP/GPC/server state, fresh
// ---- PENDING: three-member validated context only; NO data-plane event ----
if consent.lifecycle == "pending":
holdPendingContext(validatedPendingContext()) // {utm_source, utm_medium,
return NO_DATA_PLANE_EVENT // utm_campaign} | null — nothing else
// No stored attribution is read, built, or persisted while pending.
params = parseQueryOnce(url) // single urlencoded parse, first-occurrence-wins
// ---- RESOLVED, analytics DENIED ----
// Generic automatic capture has no ad-action authorization and therefore
// NEVER yields an ads-only context. Ads-only handling exists solely as the
// explicitly invoked operation below.
if consent.effective.analytics_storage != "allowed":
return NO_DATA_PLANE_EVENT
// ---- RESOLVED, analytics ALLOWED: full capture ----
utms = extractUtms(params) // normalized per the pipeline
click_ids = {}
if consent.effective.ad_storage == "allowed":
click_ids = extractClickIds(params) // <=16 keys; decoded scalars,
// byte-preserved, 1-512 bytes;
// {value, captured_at, expires_at};
// oversize/empty rejected, never truncated
custom = extractCustomParams(params) // custom contract, fail-closed
existing = loadAttribution()
// Boundary first: navigation-kind + (new attribution signal OR cross-site provenance)
if reliableBoundary(navigation, utms, click_ids, custom):
accept(existing, buildTouchpoint(utms, click_ids, custom,
referrerDomain(navigation), landingUrl(url), touch_id: newUUIDv4()))
else:
fp = fingerprint(utms, click_ids, custom, navigation, url) // pre-JCS contract
if matchesLastTouchWithinWindow(existing, fp):
if enrichmentOnly(existing, click_ids): // ad_storage denied->allowed backfill
backfillClickIds(existing.last_touch, click_ids)
existing.is_new_touch = false // count unchanged either way
else if hasNewAttribution(utms, click_ids, custom, navigation):
accept(existing, buildTouchpoint(..., touch_id: newUUIDv4()))
else:
existing.is_new_touch = false
// Reconciliation is EXECUTABLE and shared — but scoped strictly to MUTABLE
// live state and pre-freeze assembly. Its result, never the assembled full
// record, is what gets persisted and returned.
return reconcileAndPersist(existing, explicitAction: null)
function reconcileAndPersist(record, explicitAction):
fresh = readAuthoritativeConsent() // full vector + lifecycle, fresh
if fresh.lifecycle == "pending": // flipped back to pending:
purgeLiveAttribution() // nothing stores, nothing sends
return NO_DATA_PLANE_EVENT
if fresh.effective.ad_storage != "allowed":
stripClickIdsEverywhere(record) // every live occurrence —
// first_touch AND last_touch —
// before any write or send
if fresh.effective.analytics_storage != "allowed":
purgeLiveAttribution() // the assembled full record is
// never returned or persisted
if explicitAction != null and fresh.effective.ad_storage == "allowed"
and authorizes(fresh, explicitAction):
// Re-extract purpose-limited click IDs from the ORIGINAL request
// context — never derived from the assembled/stored record.
return adsOnlyMemoryContext(
extractClickIds(originalRequestContext()), explicitAction)
return NO_DATA_PLANE_EVENT
storeAttribution(record) // LS + SS; bounds enforced pre-write
return record // the reconciled, authorized result
// SEPARATE, EXPLICITLY INVOKED operation — never called by generic capture.
// `action` is a concrete action-purpose/authorization input from the caller.
function captureForExplicitAdAction(requestContext, action):
consent = readAuthoritativeConsent()
if consent.lifecycle != "resolved" or consent.effective.ad_storage != "allowed"
or not authorizes(consent, action): // fresh vector, THIS action
return NO_DATA_PLANE_EVENT
// Purpose-limited, in-memory only; from the original request context;
// nothing is read from or written to stored attribution.
return adsOnlyMemoryContext(extractClickIds(requestContext), action)
// SEND-BOUNDARY SCOPE: reconcileAndPersist governs mutable live state and
// pre-freeze assembly ONLY. An already-frozen current or retry body never
// passes through it — the send-time consent/expiry gate either dispatches
// the byte-identical authorized body or drops the whole entry terminally
// (UIAF-03-RETAIN-001).
// Negative fixtures: pending flip mid-assembly; analytics allowed->denied;
// ad allowed->denied stripping BOTH touches; no-explicit-action (generic
// capture under analytics denial -> NO_DATA_PLANE_EVENT); stale-action-auth
// (action no longer authorized by the fresh vector -> NO_DATA_PLANE_EVENT);
// frozen-body consent-downgrade -> byte identity preserved through whole drop.

Reload of a campaign landing. Reload is not a boundary; the in-window fingerprint match preserves the touch and its touch_id. Five refreshes are one touch.

Two campaigns, one session. A second classified navigation with a new click ID is a new touch: distinct click IDs always yield distinct fingerprints even inside the window.

Same newsletter link clicked twice quickly. Two UTM-only arrivals with no boundary signal and identical fingerprints inside the window honestly collapse into one touch. This is the accepted, disclosed ambiguity of the heuristic.

Click ID expired but still in the URL. Capture with fresh captured_at/expires_at; the endpoint and platforms judge attribution validity downstream.

Direct visit after a campaign visit. No boundary, no new attribution: no update.

User clears storage. Cleared attribution remains cleared. Identity recovery (Identity Management) restores identity only; localStorage absence is authoritative for attribution, and a surviving sessionStorage cache or downstream/server-side history is never promoted or rehydrated into the client. Only a newly authorized touch creates new attribution.



BCP 14 requirement keywords are normative only inside the identified blocks below; all other prose in this section is explanatory.

  • UIAF-03-PEND-001 — While the consent lifecycle is pending, the core MUST hold only the registry-validated three-key current-memory context (utm_source, utm_medium, utm_campaign), MUST NOT build a touchpoint or capture click IDs, custom parameters, referrer, or landing data, MUST NOT write any attribution state to storage, and MUST NOT perform any data-plane send.
  • UIAF-03-PEND-002 — The pending-consent context MUST be dropped on pagehide so that no BFCache snapshot can carry it, and nothing held from before a pageshow with persisted === true MUST be used until authoritative consent has been re-evaluated. no-store MUST NOT be treated as a memory-lifecycle control, and no conformance path MUST rely on unload listeners.
  • UIAF-03-CAPTURE-001 — Attribution capture MUST read authoritative consent inside the capture path, and MUST re-evaluate the full effective-permission vector immediately before persistence and before every send, synchronously purging, rebuilding, or dropping every now-disallowed field. A stale click-bearing record MUST NOT be written or sent after an ad_storage downgrade.
  • UIAF-03-RETAIN-001 — Expired click-ID values MUST be pruned from live stores and omitted during assembly before body freeze. If any click-ID occurrence in an already-frozen retry body has reached its expires_at, the entire entry MUST be dropped terminally; a frozen body MUST NOT be edited, pruned, sanitized, or rerouted.
  • UIAF-03-TOUCH-001 — A touchpoint MUST carry the seven required scalar keys source, medium, campaign, term, content, referrer, landing_url, each string or explicit null, and MUST NOT substitute any filler, default, or inferred literal for an absent value. touch_id and timestamp MUST be present and non-null. Every click_ids value MUST be a present object/map. first_touch.custom MUST be a present object/map. last_touch.custom MUST be a present object/map, except it MAY be null only under the frozen compact profile with last_touch.term = null, last_touch.content = null, and the envelope client group null (step 1 preceding step 2). Campaign-scalar-only with retained canonical landing URL, URL-provenance-only, and all-scalars-null accepted touches are valid shapes and MUST validate (frozen fixtures touch-campaign-only-scalars-null.json, touch-cross-site-utms-null.json, touch-all-scalars-null.json). (Frozen negative fixtures: touch-missing-source-key.json, touch-source-nonstring.json, touch-source-empty-string.json, touch-source-sentinel-direct.json.)
  • UIAF-03-STORE-001 — localStorage is authoritative for live attribution. When the authoritative localStorage record is absent or cleared, attribution MUST be treated as cleared: a surviving sessionStorage cache MUST NOT be promoted, and downstream/server-side history MUST NOT be rehydrated into the client. Identity recovery restores identity only and MUST NOT restore attribution. Only a newly authorized touch MAY create new attribution state. (Negative fixtures: localStorage cleared while a sessionStorage copy survives → no attribution restored; identity recovered under continuity while server-side history exists → identity restored, attribution empty.)