Caveats & known limits

Caveats & known limits

A candid list of how Tocadule's data model actually behaves at the edges — the things worth knowing before you rely on a behaviour, and the practical implication of each. This page doubles as the reference an AI agent should read before generating tracking code or answering "why is X happening".

Everything below is grounded in the live ingestion path (/events/track, /events/identify) and the snippet loader — not aspirational behaviour.

The install is a stub + async loader

The snippet loads asynchronously. A tiny stub queues any calls made before snippet.js finishes downloading into tocadule._q, and the snippet drains that queue on init. This is why you never wrap calls in if(window.tocadule) on a per-page basis — paste the stub once site-wide and call the API bare.

<script>window.tocadule=window.tocadule||{};window.tocadule._q=window.tocadule._q||[];['track','identify','cart','setCart','requestPush'].forEach(function(m){window.tocadule[m]=window.tocadule[m]||function(){window.tocadule._q.push([m,arguments])}});</script>
<script async data-workspace="WORKSPACE_ID" src="SNIPPET_HOST/snippet.js"></script>

The public API surface is exactly four calls: track(name, props), cart(data), identify(email, traits), and requestPush(cb) (setCart(total) is a legacy alias for cart).

⚠️

The MANUAL_SCRIPTS shown on the Events page do include an if (window.tocadule) {…} guard because they are self-contained examples. That guard is only safe once the stub above is present in your theme header — the stub makes window.tocadule truthy immediately, so the guard passes and the call is queued. Without the stub, an early inline call is silently lost.

Linking is forward-only unless you call identify

You can attach contact identifiers — email, phone, first_name, last_name, external_id — to any event. If the visitor isn't known yet, we auto-create the contact and link it to their anonymous_id; if they are known, we enrich. So identify() is not required before every event.

There are two valid ways to link a visitor to a contact, and they differ in one important way:

Linking pathWhat it doesBackfills prior anonymous events?
identify() onceUpserts the contact, links anonymous_id, and updates all prior events that had this anonymous_id and no contactYes — full backfill
Email on a track() eventResolve-or-create the contact from the email on that eventNo — links from that event forward only

The backfill is literally an UPDATE events SET contact_id = … WHERE anonymous_id = … AND contact_id IS NULL that only identify runs. Email-on-an-event links the contact and stamps it on that row (and every later row via the identity map), but events that already landed anonymously stay anonymous.

Implication: a merchant needs at least one linking signal per visitor. If a logged-in shopper browses ten products anonymously and only their purchase carries an email, those ten product_view rows remain unattributed — the purchase and everything after it are linked, the ten before it are not. To credit the whole journey, call identify() once at login/sign-up. To lift match quality cheaply on the events that matter, pass email on them directly:

<script>
  if (window.tocadule) {
    var sub = function(v){ return v && String(v).indexOf('{') < 0 ? v : undefined; };
    tocadule.track('product_view', {
      product_id: sub('{productId}'),
      name: sub('{productName}'),
      price: parseFloat('{productPrice}') || 0,
      currency: 'TRY',
      brand: sub('{productBrand}'),
      category: sub('{productCategory}'),
      product_image_url: sub('{productImage}'),
      product_url: window.location.href,
      // Optional — if the shopper is logged in, pass their email. We auto-link the
      // contact (no separate identify needed) and match quality on this event jumps.
      email: sub('{email}')
    });
  }
</script>

Newly auto-created contacts from an event-carried email are not marketing opt-ins — they are inserted with subscribed=false on every channel. Only an explicit identify() (or a preference form) sets subscription intent. Putting an email on an event links a contact; it does not sign them up for email/SMS.

cart() is PII-free by design

tocadule.cart() carries the cart contents only — items, total, currency, item count. It resolves to a contact through the anonymous_id → identity map join, so it never needs (and should never carry) an email.

<script>
  // Send your WHOLE current cart on EVERY change (add / remove / quantity).
  // Build items[] by looping over every line in the cart — one object per line.
  // The values below are EXAMPLES — replace them with your real cart data.
  //   REQUIRED per item: product_id, name, price (number), quantity (number)
  //   OPTIONAL per item: image, url  (make the cart cards look nice)
  if (window.tocadule) {
    tocadule.cart({
      items: [
        { product_id: '101', name: 'Blue T-Shirt', price: 199.90, quantity: 2, image: 'https://yourshop.com/img/blue.jpg', url: 'https://yourshop.com/blue-tshirt' },
        { product_id: '250', name: 'Black Cap',    price: 149.00, quantity: 1, image: 'https://yourshop.com/img/cap.jpg',  url: 'https://yourshop.com/black-cap' }
        // …repeat one object for EACH product currently in the cart
      ],
      total: 549.80,     // cart subtotal as a number
      currency: 'TRY',
      item_count: 3       // total quantity across all lines
      // No email / PII here on purpose — the cart stays lean. Once any other event
      // carries the shopper's email (purchase, or a logged-in product_view /
      // checkout), we link the contact and the cart resolves off the anonymous_id.
    });
  }
</script>

The universal cart interceptor (which reads carts by shape from network responses on supported platforms) only reads cart contents too — it never scrapes customer identity.

Implication: the cart resolves to a person only once some other signal links that anonymous_id to a contact. A cart built entirely under a cookieless visitor stays anonymous until a linking event arrives. Don't try to "fix" this by putting an email on the cart call — that field is ignored by design; put it on a real event (purchase, logged-in checkout_started, etc.) instead.

Dedup keys are automatic — you never set them

Deduplication uses two keys, both assigned by the system:

  • anonymous_id — browser identity, minted and stored by the snippet (consent-gated). Used to resolve a visitor to a contact.
  • source_event_id — external-system dedup key, set by server-side ingestion (Shopify webhooks: shopify-order-{id}, etc.) and by the internal identify per-day key.

Implication: merchants never pass a dedup id in track(). There is no "idempotency key" parameter you should be setting. Repeat browser events from the same visitor are expected and kept — we deliberately do not collapse legitimately-repeated events (e.g. viewing the same product twice) into one.

Consent is two lanes — nothing else

Tocadule reads exactly two CMP consent lanes, detected automatically from the merchant's consent platform (Google Consent Mode, IAB TCF v2, OneTrust, Cookiebot, Iubenda, GPC, or an explicit override):

LaneFieldGates
Analytics / generalconsent_stateStoring anonymous_id + PII
Advertisingad_consentForwarding to ad destinations (Meta / Google / TikTok)

Storage behaviour on the analytics lane is store-unless-denied: granted, unknown, and absent all store; only an explicit denied suppresses anonymous_id + contact creation from event-carried identity. Latitude/longitude enrichment is the one extra thing gated on an explicit granted.

The advertising lane resolves independently to full / ldu / modeled and governs only what leaves for ad platforms — analytics consent never turns ads on, and identify() never grants ad consent.

⚠️

Email/SMS opt-in (subscribed_email / subscribed_sms) is a merchant preference, not a CMP lane. It is set through identify() traits or a preference form — never inferred from a consent banner. Don't conflate "the visitor accepted analytics cookies" with "the visitor subscribed to email".

Implication: if a visitor's CMP reports analytics denied, that visitor stays cookieless (no anonymous_id, counted only via the daily IP+UA visitor_session_hash) and no contact is auto-created from an email on their events. If ad consent is denied but analytics is fine, you still get analytics — you just don't forward to Meta/TikTok/Google.

First-party (custom-domain) requests expose only country + ASN from CloudFront

When a merchant serves the snippet from their own first-party domain (data.<merchant>.com via CloudFront SaaS Manager), CloudFront only populates the basic viewer headers — country code and ASN. The extended geo set (country name, region, city, postal code, time zone, lat/lon) never arrives on those distributions, no matter what the origin request policy whitelists.

To keep location complete everywhere, MaxMind (GeoLite2 City, offline) is the single source of truth for location — country, region, city, postal, lat/lon, time zone are resolved from the real client IP on every event. CloudFront is fallback and provides ASN (used for bot detection only).

Implication: location on events is populated regardless of distribution, so By-Geo segments and Top-Countries work for first-party-domain merchants. Location segments are event-based — they read properties.country off events, not off the contact profile.

Contact profile location (country / city on the contact record) is declared-only — set from identify() traits (traits.country / traits.city, typically the account or shipping address), never inferred from the request IP. An IP says where a request came from (proxy / VPN / travel / prefetch), not where a person lives, so we never freeze an IP-inferred location onto a contact.

To populate profile location, send it explicitly:

<script>
  // Call this ONCE, at sign-up / login / newsletter opt-in — it's the richer
  // one-time profile call (sets subscription intent + full name/location).
  // After this you do NOT need to call identify again: just pass email on your
  // events (optional) and we keep the contact linked. Everything except email is
  // optional — country + city unlock location segments.
  if (window.tocadule) {
    tocadule.identify('{email}', { first_name: '{firstName}', last_name: '{lastName}', phone: '{phone}', country: '{country}', city: '{city}' });
  }
</script>

Custom / arbitrary event names pass through

track() accepts any event name. The only requirement at ingestion is a workspace_id and a non-empty event name — there is no allow-list. An unknown name is stored verbatim as an events row like any standard event.

What an unknown event does not get:

  • It is not aggregated into the canonical Data Collection rows (those are the fixed set: page_view, product_view, add_to_cart, cart_state, checkout_started, purchase, identify, etc.) unless its name is a known alias of one of them.
  • It is not counted in the pixel gap report (only the five commerce events product_view, add_to_cart, checkout_started, purchase, search are).
  • It is not forwarded to an ad destination unless a destination mapping exists for that exact name (mapping is exact-match — we never guess aliases at runtime).

Where an unknown event does appear: in the raw Events list, in the distinct-event-names list, and in the per-event catalog summary (fire count, unique contacts, last fired). So a custom event is fully captured and queryable — it just isn't part of the standard funnel/forwarding contract until you map it.

Implication: custom events are safe to send and useful for your own analysis, but don't expect a brand-new event name to automatically show up in funnels, drive an ad forward, or feed a canonical segment. Segments and destinations key off the canonical events; wire a mapping (or use a canonical name) if you need those behaviours.

Quick reference

BehaviourRule
identify() before every eventNot required — put identifiers on any event
Email on an eventLinks contact forward-only, no backfill
identify() callLinks + backfills prior anonymous events
Email on cart()Ignored — cart is PII-free by design
Dedup id parameterNone — anonymous_id / source_event_id are automatic
Consent lanesTwo: consent_state (analytics) + ad_consent (advertising)
Email/SMS opt-inMerchant preference, not a CMP lane
Analytics storageStore unless explicitly denied
Location sourceMaxMind from client IP (single source of truth)
Contact profile locationDeclared-only, from identify traits
CloudFront on first-party domainCountry + ASN only
Custom event namesAccepted + stored; not in canonical funnel/forwarding unless mapped