Consent & compliance

Consent & compliance

Tocadule tracks consent on two independent lanes, sent on every event:

LaneField on the eventWhat it gates
Analyticsconsent_stateWhether we store the visitor's anonymous_id and personal data (PII) at all
Advertisingad_consentWhether — and how much of — an event is forwarded to ad destinations (Meta, Google Ads, TikTok)

These are separate on purpose: a visitor can allow analytics but deny advertising, and the two decisions never leak into each other. A newsletter/SMS opt-in is a third, unrelated thing — a merchant preference, not a CMP lane (see Email & SMS subscription).

Each lane carries one of three values:

  • granted
  • denied
  • unknown — no CMP signal detected (the default before a banner resolves)

Anything else the snippet might send is normalized to null server-side, which means "defer to the workspace's configured posture."


The analytics lane — consent_state

consent_state decides whether we keep an identifiable record of the visitor.

We store unless the lane is explicitly denied. granted, unknown, and null all store. Only an explicit denied turns storage off. Concretely, when the snippet is in analytics-denied ("cookieless") mode:

  • No anonymous_id is written to localStorage or cookies, and none is sent on events — the backend counts the visit through a daily-rotating visitor_session_hash (IP + user-agent) instead, so analytics still work without a persistent identifier.
  • The analytics browser ID we bake (_ga) is removed.
  • Popup throttle/impression state moves from localStorage to sessionStorage (session-scoped, treated as strictly-necessary).
  • Latitude / longitude are only attached when consent_state === 'granted'. Country / region / city / postal are always resolved (aggregate-level, needed for the dashboards); precise lat-lon is gated.
  • A visitor who denies analytics but later shares an email on an event will not be auto-linked to a contact — the event-identity upsert is skipped when the analytics lane is denied.

identify() is an explicit PII share (login, sign-up, opt-in), so calling it grants the analytics lane even if the banner was previously denied. It does not grant the advertising lane — sharing your email for a receipt is not opting into ad targeting.

The advertising lane — ad_consent

ad_consent never affects your dashboards. It only controls what leaves Tocadule for ad platforms, and it does so through three send treatments, resolved from the lane value plus the workspace posture:

ad_consentTreatmentWhat is sent
grantedfullFull server-side event incl. hashed PII + click IDs — highest match quality
unknown (workspace permissive)full or lduDestination's default button decides
unknown (workspace cookieless)modeledTreated as non-consented
deniedmodeledConversion signal only
  • full — everything: hashed email/phone/name + click IDs.
  • ldu — Limited Data Use: strip PII, keep click IDs (attribution still works, nothing personal).
  • modeled — strip PII and click IDs; a minimal, non-persistent conversion signal only. (Google Ads has no identifier-less upload path, so modeled there means skip — nothing is sent.)

On the browser side, denying the ad lane also deletes the ad browser-IDs we baked (_fbp, _ttp, _fbc). IDs the merchant's own pixel set are left alone — they belong to the merchant's own consent flow.

GA4 is gated by the analytics lane, not this one. GA4 is measurement, not advertising, so its server-side forwarding is keyed off consent_state — only Meta, Google Ads, and TikTok are gated by ad_consent. GA4 runs the same treatment ladder (full / ldu / modeled), where modeled sends Google Consent Mode "denied" flags rather than being skipped.

⚠️

GPC is a hard ad-denial. If the browser sends a Global Privacy Control signal (navigator.globalPrivacyControl === true), the advertising lane is forced to denied regardless of any CMP state.


How the snippet detects consent

You usually don't wire anything up. The snippet reads consent from whatever CMP is already on the page, checked in priority order and re-checked after 500 ms, 2 s, and on the CMP's own change events:

  1. An explicit override — window.tocadule = { _consent: 'granted' } / _ad_consent, or a <meta name="tocadule:consent"> / tocadule:ad_consent tag
  2. IAB TCF v2 (OneTrust, Cookiebot, Quantcast, Sourcepoint, Didomi, …)
  3. Google Consent Mode v2 — reads the resolved analytics_storage / ad_storage from google_tag_data.ics, so it's CMP-agnostic (efilli, cookieseal, Cookiebot, OneTrust, Usercentrics…)
  4. GPC (ad lane only — hard denial)
  5. Vendor-specific fallbacks: Iubenda, OneTrust OptanonConsent groups (C0002 analytics, C0004 advertising), Cookiebot categories

If nothing resolves, both lanes stay unknown and the workspace's configured posture (permissive vs cookieless) decides behavior.

Driving consent yourself (no CMP)

If you don't run a CMP, drive the lanes directly from your own banner:

// Analytics lane
tocadule.consent.set('granted');   // or 'denied' | 'unknown'
tocadule.consent.get();
 
// Advertising lane — independent of analytics
tocadule.consent.setAd('granted'); // or 'denied' | 'unknown'
tocadule.consent.getAd();

Calling set/setAd re-applies tracking mode immediately (bakes or clears IDs, switches storage), so it's safe to call the moment the visitor clicks your banner.

What goes on the wire

Every track() and identify() request carries both lanes — you never set them per call:

// track() body
{ workspace_id, anonymous_id, event, properties, consent_state, ad_consent }
 
// identify() body
{ workspace_id, anonymous_id, email, traits, user_language, consent_state, ad_consent }

tocadule.cart() is PII-free by design — it carries the anonymous_id and the cart contents only, and resolves to a contact through the identity map. Never put an email on the cart; the cart payload has no consent fields of its own because it stores no personal data.


Email & SMS subscription is not a consent lane

Marketing subscription (subscribed_email, subscribed_sms, subscribed_phone) is a merchant preference, completely separate from the two CMP lanes above. It governs whether you may send campaigns to a contact — not whether you may track them.

  • An email on an event is not a subscription. When an event carries an email and we auto-create the contact, it is created subscribed = false. Opt-in only comes from an explicit source.
  • identify() can set subscription intent via traits — per-channel (subscribed_email / subscribed_sms / subscribed_phone) or a single consent_etk checkbox that maps to all three. The master subscribed flag is OR(email, sms, phone).
  • A preference center fires a preference_updated event with the per-channel booleans to update state later.
// Sign-up with explicit opt-in
tocadule.identify('shopper@example.com', {
  first_name: 'Ada',
  subscribed_email: true,
  subscribed_sms: false
});

Identity, dedup & geo (compliance notes)

Linking a visitor to a contact needs at least one signal, and there are two valid paths:

  1. identify() once — links the contact and backfills the visitor's prior anonymous events.
  2. An email (or phone / name / external_id) on an ordinary event — links from that event forward only (no backfill).

Either is fine; you don't need to call identify() before every event. See Sending events for the field contract.

Deduplication is automatic. Browser events dedupe on anonymous_id; external/server events dedupe on source_event_id. Merchants never set dedup IDs.

Location is handled two ways, deliberately:

  • Event location is resolved from the real client IP via MaxMind (offline GeoLite2) — the single source of truth for country / region / city / postal / timezone (and lat-lon when analytics consent is granted). Location segments read properties.country off events.
  • Contact-profile location is declared-only. It is set solely from identify() traits (country, city) — never inferred from an IP. An IP says where a request came from (proxy / VPN / travel), not where a person lives, so we never freeze an inferred country onto a contact.

Custom events

Any event name is accepted — tocadule.track('wishlist_added', {...}) stores exactly as sent. Unknown names are treated like every other event: written to the events table, visible in the raw event stream on the Events page, and usable in segments and automations by their literal name.

What they don't get is the built-in canonical treatment: only recognized names (and their aliases) roll up into the Data Collection cards, the commerce funnel, and the ad-platform gap report, and only recognized commerce events map to a standard destination event (Meta AddToCart, GA4 view_item, …). Prefer the standard event names when a matching one exists; reach for a custom name only when nothing fits.