Identity and consent

How the SDK stores identity, captures attribution, and honours consent.

Everything on this page is client-side behaviour, shared by every SDK binding. For what the backend does with the identifiers once they arrive, see Identity resolution.

What the SDK stores

Keys are prefixed with storagePrefix, which defaults to __ut_. Cookies are written SameSite=Lax, with Secure per the secureCookies option. Reads try cookie first, then localStorage, then sessionStorage — so clearing one layer doesn’t lose the visitor.

KeyWhereLifetimeHolds
aidCookie + localStorage365 daysanonymous_id (anon_<uuid>)
sidCookie + sessionStorage30 minutesSession id (sess_<uuid>)
ssCookie + sessionStorage30 minutesSession start time
lsCookie + sessionStorage30 minutesLast-seen timestamp
uidCookie + localStorage365 daysuser_id after identify()
cmpCookie + localStorage30 daysCampaign / UTM parameters
cidsCookie + localStorage30 daysCaptured click IDs
optoutCookie + localStorage365 daysDurable opt-out flag
qlocalStorage onlyUntil sentOffline queue, max 100 events FIFO

The day figures track anonymousIdTTL and attributionTTL, the minutes track sessionTimeout — see Configuration.

The /id call and Safari ITP

On init the SDK issues GET /id and adopts the anonymous_id the server returns, alongside an httpOnly cookie the server sets for 365 days.

The reason is Safari’s Intelligent Tracking Prevention: a cookie written by JavaScript is capped at roughly 7 days. A visitor who returns after a fortnight looks brand new, breaking the link between the ad click and the purchase. A cookie set by the server in an HTTP response header isn’t subject to that cap, so the server-issued id survives.

The fetch is non-blocking. Events sent before it resolves use the client-generated id, and the backend stitches the two together during identity resolution. Nothing is dropped waiting for it.

This only helps when the collector is same-site with your pages. Serving it from your own subdomain via first-party domains is what makes the server cookie first-party rather than third-party.

Cross-domain linking

Cookies can’t cross registrable domains. To carry one visitor between example.com and example-checkout.com, append the linker parameter to outbound links:

1import { getLinkerParam } from '@pivolio/browser';
2
3const href = `https://example-checkout.com/cart?${getLinkerParam()}`;

getLinkerParam() returns _ut=<url-encoded anonymous_id>. On the receiving page the SDK reads _ut from the URL — and then applies one rule that matters:

An inbound _ut is adopted only when the visitor has no stored id yet. A returning visitor’s id is never overridden.

Without that rule, anyone could craft a link carrying their own id, share it, and merge every recipient into one person — corrupting the identity graph and, since identity drives conversion attribution, your reporting with it. Seeding a fresh visitor is safe; overwriting a known one is not.

_ut is always stripped from the address bar, adopted or not, so a third party’s id can’t bleed into the captured page URL and get logged as page context.

For subdomains of one site you don’t need the linker at all — set cookieDomain: '.example.com' and the cookie is shared.

Click ID capture

Click IDs arrive by two routes, and the SDK treats them differently.

From the URL, on any page load — 11 parameters: fbclid, gclid, gbraid, wbraid, ttclid, li_fat_id, msclkid, epik, rdt_cid, sc_click_id, twclid. These are persisted for attributionTTL days and merged, not replaced. A visitor who arrives from Google and later returns from Meta keeps both, because the server can only backfill a click it still knows about.

From platform cookies, re-read fresh on every single event — fbp from _fbp, fbc from _fbc, ttp from _ttp. These are written by Meta’s and TikTok’s own pixels if you also run them. They’re read live rather than cached because the platform pixel can rewrite them mid-session, and a stale value is worse than none: it lowers your match quality while looking like a signal.

Only 4 of the 9 platforms Pivolio recognizes are delivery destinations — Meta, Google Ads, TikTok and Reddit. The other click IDs are captured purely for attribution.

UTM parameters

utm_source, utm_medium, utm_campaign, utm_content and utm_term map to campaign.source, .medium, .name, .content and .term.

Unlike click IDs, a new campaign replaces the stored one. The asymmetry is deliberate. Click IDs are per-platform credentials — losing one loses a delivery match. A campaign is a single answer to “how did this visitor last get here”, and merging two campaigns produces a chimera that belongs to neither.

setConsent() takes a Google Consent Mode v2 object. Three categories change behaviour, and unset is treated as not-denied:

1import { setConsent } from '@pivolio/browser';
2
3setConsent({
4 ad_storage: 'granted',
5 ad_user_data: 'granted',
6 ad_personalization: 'denied',
7});
  • ad_storage: 'denied' — the event is not sent at all.
  • ad_user_data: 'denied' — PII traits are stripped; the event still sends.
  • ad_personalization: 'denied' — ad click IDs are stripped.

Denying ad_storage also immediately discards anything still buffered, so events captured before the visitor declined are never flushed afterwards.

Three independent kill switches

They are not variations of one setting, and turning one off doesn’t turn the others off.

  • respectDNT — init-time. When the browser sends Do Not Track and this is enabled, the tracker never initializes.
  • optOut() — durable and persisted. Survives reloads via the optout key. Reverse it with optIn(); query it with hasOptedOut().
  • ad_storage: 'denied' — runtime, and not persisted. Re-apply it on each page load from your own consent store.

An opted-out visitor is fully inert on later loads: no /id call, no auto page view, no events. The local anonymous_id is kept — it backs the public getters — but nothing ever leaves the device.

Next steps