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 path | What it does | Backfills prior anonymous events? |
|---|---|---|
identify() once | Upserts the contact, links anonymous_id, and updates all prior events that had this anonymous_id and no contact | Yes — full backfill |
Email on a track() event | Resolve-or-create the contact from the email on that event | No — 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 internalidentifyper-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):
| Lane | Field | Gates |
|---|---|---|
| Analytics / general | consent_state | Storing anonymous_id + PII |
| Advertising | ad_consent | Forwarding 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,searchare). - 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
| Behaviour | Rule |
|---|---|
identify() before every event | Not required — put identifiers on any event |
| Email on an event | Links contact forward-only, no backfill |
identify() call | Links + backfills prior anonymous events |
Email on cart() | Ignored — cart is PII-free by design |
| Dedup id parameter | None — anonymous_id / source_event_id are automatic |
| Consent lanes | Two: consent_state (analytics) + ad_consent (advertising) |
| Email/SMS opt-in | Merchant preference, not a CMP lane |
| Analytics storage | Store unless explicitly denied |
| Location source | MaxMind from client IP (single source of truth) |
| Contact profile location | Declared-only, from identify traits |
| CloudFront on first-party domain | Country + ASN only |
| Custom event names | Accepted + stored; not in canonical funnel/forwarding unless mapped |