Identity & contacts

Identity & contacts

Tocadule tracks two kinds of subject: the anonymous visitor (a browser, identified by anonymous_id) and the contact (a person, identified by email). This page explains how the two get joined, so a shopper's browsing history, cart, and purchase all attach to one contact — and so your server-side forwards to Meta / TikTok / Google carry a real identity instead of an anonymous hit.

The three-part join

Every event you send is stored against an anonymous_id. A contact is a row keyed by (workspace_id, email). The two are connected by the identity_map table — one row per (workspace_id, anonymous_id) → contact_id.

anonymous_id  ──►  identity_map  ──►  contact
(the browser)      (the join)         (the person: email, name, phone, …)

When an event arrives, Tocadule looks up the anonymous_id in identity_map. If a contact_id is found, the event is stamped with it (so the contact's timeline and match keys include this event). If not, the event stays anonymous — until a linking signal creates the mapping.

anonymous_id is the browser's ID, stored client-side as the _toc_aid cookie / localStorage value. The snippet generates it for every visitor except in cookieless mode — when analytics consent is denied (or the workspace is configured cookieless) it clears _toc_aid and sends no anonymous_id at all, and the backend counts those visits via a rotating session hash instead. You never set it yourself — the snippet manages it.

You do NOT need identify() before every event

This is the most important rule on this page. You can put contact identifiers — email plus optional phone, first_name, last_name — directly on any event.

  • email is the linking key. If the visitor is not yet known, an event that carries a real email makes Tocadule auto-create the contact and link it to their anonymous_id.
  • If the visitor is known, the email resolves to the existing contact and the other fields (phone, first_name, last_name) enrich it.
  • Passing phone or a name without an email does not create or link a contact on its own — email is what resolves identity on an event.

So a logged-in shopper's product_view or checkout_started can simply carry their email, and the contact gets linked with no separate identify() call. This is what lifts match quality (EMQ) on browsing events: a server-side forward that carries a hashed email is a strong match instead of an anonymous one.

A merchant needs at least one linking signal per visitor — either an identify() call or an email on an event. Without any, the visitor stays anonymous forever.

Two linking paths

There are two valid ways to link a visitor to a contact, and they differ in one crucial respect: whether prior anonymous events get backfilled.

Path 1 — identify() once (links + backfills)

Call identify() once, at sign-up / login / newsletter opt-in. Besides linking, it backfills: every earlier event from that anonymous_id that was still anonymous gets updated with the new contact_id. So the shopper's whole prior session retroactively attaches to the contact.

<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>

identify() also does more than link: it upserts the full contact profile, records the marketing permissions you pass with it, and — because sharing your email is an explicit action — grants the analytics consent lane for this visitor. It does not grant advertising consent (see Consent).

⚠️

Identifying someone does not subscribe them. A contact created by identify() (or by an email on an event) can be messaged only if you pass the marketing permission your form collected — otherwise email / SMS automations and campaigns skip them. Send it with the same call:

// one checkbox on your sign-up form → all channels
tocadule.identify('{email}', { consent_etk: true });
 
// or per channel, e.g. from a preference centre
tocadule.identify('{email}', { subscribed_email: true, subscribed_sms: false });

Path 2 — email on events (forward-only, no backfill)

If an event carries a real email and the visitor isn't linked yet, Tocadule resolves-or-creates the contact and writes the identity_map row. The event that carried the email — and every event after it — is linked. But events that fired before the link are not backfilled; they stay anonymous.

<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>
⚠️

Forward-only caveat. The auto-link from an email on an event runs only when the visitor is not already linked, and it does not rewrite history. If a visitor viewed three products anonymously and then an event carried their email, those three earlier product views remain anonymous. Only identify() backfills. A contact created this way is also not auto-subscribed — putting an email on an event is not a marketing opt-in.

Comparison

identify() onceEmail on events
Creates / enriches contactYesYes
Writes identity_map linkYesYes
Links the current eventn/a (own identify event)Yes
Links future eventsYesYes
Backfills prior anonymous eventsYesNo (forward-only)
Sets subscription intentYes (subscribed)No (not subscribed)
Grants analytics consent laneYesNo (respects existing consent)
Stores full profile (name, phone, country, city)YesName + phone only
When to callOnce, at sign-up / login / opt-inOn any event, as often as you like

The two paths compose, but you rarely need both:

  • Visitors who sign in or register — one identify() is enough, for the whole life of that visitor. Every later event is attached to the contact server-side and forwards with the hashed email; you do not repeat the email anywhere. Calling identify() again with the same email is ignored (once per visitor per 30 days), so it is safe to leave it on a "member logged in" template.
  • Guest shoppers — nobody ever calls identify() for them, so the only place their email exists is the order. Put email on your purchase event (or let the Shopify order webhook do it for you — it carries the customer email server-side, no page code). Without that, the buyer stays anonymous and the purchase forwards without a match key.
  • Browsing events (product_view, checkout_started) only need an email while the visitor is still unknown; once linked, adding it changes nothing.

identify(email, traits)

Signature: tocadule.identify(email, traits). Email is required; every trait is optional.

TraitEffect
first_nameStored on the contact
last_nameStored on the contact
phoneStored on the contact (a match key for ad platforms)
countryContact profile location — declared only (see below); unlocks location segments
cityContact profile location — declared only
subscribed_email / subscribed_sms / subscribed_phoneMarketing permission per channel (sign-up checkbox or preference centre). Without one of these the contact is stored but never messaged.
consent_etkOne checkbox for all three channels (the single "I accept commercial messages" box). true subscribes email + SMS + phone

Contact location is declared-only. A contact's country / city are set only from the traits you pass to identify() (account or shipping address) — never inferred from the request IP. An IP says where a request came from (proxy / VPN / travel), not where a person lives, so inferring and freezing it would permanently mislabel contacts. Event rows still carry full IP-based geo for analytics and the By-Geo segment, which is event-based. See Geo & location.

Enrichment is non-destructive: on an existing contact, blank traits never overwrite stored values. identify() also emits a canonical identify event (deduped once per visitor per day), which is what downstream consumers — Meta CompleteRegistration, the analytics funnel, automations — key on regardless of what your sign-up form is named.

Cart is PII-free by design

tocadule.cart() carries only cart contents — items, total, currency, item count — plus the visitor's anonymous_id. Never put an email on the cart. It resolves to the contact through the same identity_map join once any other signal links the visitor.

<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 reader also captures cart contents passively from your store's own network responses — it reads only the cart, never identity.

Consent and identity

Consent has two lanes, and identity interacts with the first:

  • consent_state (analytics / general) — gates whether we store the anonymous_id and PII. We store unless it is explicitly denied. An email on an event is written unless this lane is denied; identify() grants this lane (an explicit PII share).
  • ad_consent (advertising) — gates what actually forwards to ad destinations (full / LDU / modeled). identify() never grants this lane — sharing your email for a receipt is not opting into ad targeting.

Email / SMS opt-in (subscribed_email, subscribed_sms) is a separate merchant preference, not a CMP lane. It's set through identify() traits or a preference page, and controls who you're allowed to message — independent of tracking consent.

Geo and location

Location on events comes from MaxMind (GeoLite2, offline) resolved from the real client IP — the single source of truth for country / region / city / postal / lat-lon / timezone. (CloudFront is a fallback and supplies ASN for bot detection.) These land in the event's properties, and the By Geo segment reads properties.country from events.

Location on the contact profile is separate and declared-only — set from identify() traits country / city, never from IP. Lat/lon are attached to events only when the analytics consent lane is granted.

Dedup

You never set deduplication IDs. Tocadule dedups on:

  • anonymous_id — browser events.
  • source_event_id — external systems (Shopify webhooks, future connectors), a full unique index.

Custom event names

Any event name is accepted — tocadule.track() requires only a workspace and an event name, with no fixed vocabulary. A custom event is stored like any other and shows up in the dashboard's Events list, in the distinct-names picker, and in the event catalog (last 30 days, non-bot), where you can inspect its property keys and sample values.

Custom events don't get a pre-built card on the Data Collection page — that page shows a fixed set of standard events (page view, product view, collection view, view cart, add to cart, search, cart state, checkout started, purchase, purchase cancelled, identify, plus the Shopify-only checkout-abandoned / order-fulfilled events). And custom names are not aliased or normalized: only exact standard names feed the standard-event pipelines (gap report, ad-destination mapping). Use a standard name when you mean a standard event; use a custom name freely for anything else.

The install (for reference)

The install is a tiny async loader. A stub defines track / identify / cart / requestPush that queue calls into tocadule._q; when snippet.js loads it drains the queue, so template code that runs before the script finishes loading never silently no-ops.

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