Sending events

Sending events

Everything you send to Tocadule flows through one function:

tocadule.track(eventName, properties)

eventName is a string, properties is a plain object. That's the whole surface. Cart state has its own call (tocadule.cart(...)) and identity has tocadule.identify(...), but browsing, checkout, purchase, search and any custom signal all go through track.

Installing the snippet

Paste both tags into your storefront's <head>. The first tag defines a tiny stub so that tocadule.track(...) exists immediately; the second loads the real snippet asynchronously.

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

The stub queues every call into window.tocadule._q. When snippet.js finishes loading it drains the queue and replays each call in order. This means template code that runs before the async snippet loads — a cart script, an order-success script — never no-ops. You do not need to wrap calls in a setTimeout or a "snippet ready" check.

The manual code samples below still guard with if (window.tocadule) because they can be pasted on pages where the stub tag may be absent — the guard is harmless when the stub is present.

The payload you send vs. what we store

You only ever supply two things: the event name and a properties object. The snippet wraps that into the request to /events/track:

// What the snippet actually POSTs — you never build this yourself:
{
  workspace_id: ws,          // from data-workspace
  anonymous_id: aid,         // browser identity, managed by the snippet
  event: eventName,          // your first argument
  properties: props,         // your object + auto-enrichment (below)
  consent_state: "...",      // analytics lane, auto-detected
  ad_consent: "..."          // advertising lane, auto-detected
}

workspace_id, anonymous_id, consent_state and ad_consent are all filled in for you. Your job is the event name and the properties.

Properties: numbers must be numbers

Money and counts must be sent as JavaScript numbers, not strings. If your platform exposes values as template tokens (e.g. {productPrice}), coerce them with parseFloat / parseInt before sending. A price stored as the string "199,90" will not aggregate into revenue correctly.

PropertyTypeNotes
price, cart_total, revenue, amountnumber`parseFloat(...)
quantity, items_countnumberparseInt(...) (e.g. `
product_id, order_id, skustringkeep as strings — IDs are not math
currencystringISO code, e.g. 'TRY'
name, brand, categorystringfree text

Guard unreplaced template tokens

If you paste a snippet with {...} placeholders that your store's template engine fills in server-side, an unreplaced token (the literal string {productId}) must never be sent. Every standard script uses the same tiny sub() helper: it passes a value through only if it's non-empty and contains no {, otherwise it returns undefined (and an undefined property is simply omitted).

var sub = function(v){ return v && String(v).indexOf('{') < 0 ? v : undefined; };

Here is the real product_view script — copy it verbatim and map the {...} tokens to your platform's variables:

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

For events that must not fire at all unless key fields are real (like purchase), guard the whole call. The purchase script only sends once both order_id and a positive revenue are present, so a page that renders with unreplaced tokens sends nothing:

<script>
  // Put this on your order-success / thank-you page. Replace the {…} tokens with
  // YOUR platform's real order variables (any platform — these are placeholders).
  // order_id + revenue are REQUIRED. The event only fires once BOTH are real, so
  // an un-replaced template never sends a junk order.
  // items are OPTIONAL — we fill them from the cart automatically.
  // email/phone/name are OPTIONAL but STRONGLY recommended — they auto-link the
  // contact and give the purchase the highest match quality (this is the event ad
  // platforms optimise toward). shipping / tax / coupon are optional extras.
  if (window.tocadule) {
    var sub = function(v){ return v && String(v).indexOf('{') < 0 ? v : undefined; };
    var num = function(v){ var n = parseFloat(v); return isFinite(n) ? n : 0; };
    var orderId = sub('{orderId}');
    var revenue = num('{orderTotal}');
    if (orderId && revenue > 0) {
      tocadule.track('purchase', {
        order_id: orderId,
        revenue: revenue,
        currency: sub('{currency}') || 'TRY',
        email: sub('{email}'),
        phone: sub('{phone}'),
        first_name: sub('{firstName}'),
        last_name: sub('{lastName}'),
        shipping: num('{shippingTotal}'),
        tax: num('{taxTotal}'),
        coupon: sub('{couponCode}')
      });
    }
  }
</script>
⚠️

purchase requires order_id and revenue > 0. revenue is the field ad platforms optimise toward — send it as a number in your store's currency. Line-items are optional; if you omit them, we backfill them from the visitor's most recent cart.

What we attach automatically

You send a small object. By the time the event lands, it carries a lot more — none of which you should ever set yourself.

Added in the browser, on every track call:

  • Marketing attribution: utm_source / utm_medium / utm_campaign / utm_content / utm_term (captured from the URL, persisted 30 min).

  • Ad click IDs, nested under click_ids (captured from the landing URL):

    KeyWhere it comes fromKept for
    gclidGoogle Ads click90 days
    gbraid / wbraidGoogle Ads click on iOS (app / web). Safari removes gclid from links in private browsing, so these are often the only Google click ID90 days
    dclidGoogle Display & Video 360 / Campaign Manager click90 days
    gad_source / gad_campaignidGoogle Ads campaign markers (they come with most ad clicks, even when the click ID itself was removed)90 days
    g_session_attrsGoogle's encoded landing context (the gad_* params, landing URL, referrer, browser). It lets Google match a click that arrived without a usable click ID. Sent once, on the first event after the landing that already has an anonymous_id90 days
    msclkidMicrosoft Ads click90 days
    fbclidMeta click28 days
    ttclidTikTok click28 days
    epikPinterest click28 days
    pinclidPinterest click (older format)28 days
    srsltidGoogle Merchant Center free listing. Not an ad click; it only marks the visit as shopping28 days

    Each key keeps its own clock: a new Meta click does not reset the Google one. If the URL no longer carries an ID (a redirect removed it), the snippet also checks a same-site referrer and, with ad consent, the ad platforms' own cookies (_gcl_aw, _gcl_gb, ttclid, _epik). _click_ts is the time of the most recent click on any platform, and _click_ts_by holds the time per platform (both in milliseconds). The _fbp / _ttp / _ga browser-identifier cookies ride along too (read from the merchant's own pixel if present, otherwise baked by us for consented visitors).

  • Device: device_type (mobile / tablet / desktop), browser, os.

  • Traffic source: traffic_source, traffic_platform, referrer_domain. The landing is classified once and the result is kept for the visit (30 minutes of inactivity), so a reload or an in-site page does not turn it into direct. The first rule that matches wins:

    traffic_sourceWhen
    paidThe URL has a Google, TikTok, Microsoft, Pinterest or ChatGPT Ads (oppref) click ID, a gad_source, an fbclid with a paid utm_medium, or any paid utm_medium (cpc, ppc, paid, cpm, paid_social, display)
    aiThe referrer is an AI assistant (ChatGPT, Perplexity, Gemini, Copilot, Claude, …) or utm_source names one — organic assistant traffic only; an ad clicked inside ChatGPT is paid
    shoppingThe URL has srsltid (a free Google Merchant Center listing)
    organicA search-engine referrer, or utm_medium=organic
    referralAny other external referrer or UTM tag, or an fbclid without a paid medium
    likely_aiNo referrer, no campaign tag, and the visitor landed straight on a product page (typical of assistants that hide the referrer)
    directNone of the above

    traffic_platform is set when the platform is known: google, meta, tiktok, microsoft, pinterest, chatgpt or other.

  • Page context: url, path, title, page_locale, user_language.

  • Environment: screen_width / screen_height, viewport_width / viewport_height, dpr, connection_type.

Added / resolved on the server:

  • Contact resolution — the server takes the browser-sent anonymous_id (see the payload above) and looks up the linked contact through the identity map. This is how anonymous browsing gets stitched to a known shopper. The anonymous_id itself is generated and sent by the browser, not added server-side.
  • Location — country, region, city, postal code, timezone (and, only with analytics consent, latitude / longitude). Resolved from the real client IP via MaxMind (offline GeoLite2). You do not pass geo; passing your own country / city on the event takes precedence if you do.
  • client_ip, user_agent — captured request-side (used to improve ad-platform match quality).
  • Bot flagging (is_bot) by user-agent, so crawler traffic is excluded from analytics and never forwarded to ad platforms.

If you set a property we would otherwise auto-fill (say your own url or country), your value wins. Auto-enrichment only fills gaps.

Dedup keys — you never set them

Tocadule dedups on two keys, and merchants set neither:

  • anonymous_id — the browser key. The snippet generates and persists it; it rides on every event automatically. It's how repeated events from the same visitor collapse to one identity.
  • source_event_id — the external-system key, used for server-to-server sources (Shopify order webhooks, future integrations) so a replayed webhook can't double-count. Browser track calls do not carry one.

You don't send dedup identifiers on tocadule.track(...). Send the real event; identity resolution and de-duplication happen behind the scenes.

Attaching a shopper's identity

You can put contact identifiers — email, phone, first_name, last_name — directly on any event. When an event carries a real email:

  • If we don't yet know the visitor, we create the contact and link it to their anonymous_id.
  • If we already know them, we enrich the existing contact.

So you do not need to call identify() before every event. A logged-in shopper's product_view or add_to_cart that carries their email is a strong, matchable signal for server-side forwarding to Meta / TikTok / Google.

There are two ways a visitor gets linked to a contact, and they behave differently:

PathWhen to useBackfill?
tocadule.identify(email, traits)once, at sign-up / login / opt-inYes — links and backfills the visitor's earlier anonymous events
email on a track eventany event from a known shopperNo — links from that event forward only

Use identify() once per visitor to also attach marketing permissions (consent_etk or subscribed_email / subscribed_sms, otherwise the contact is never messaged) and profile fields (first_name, last_name, phone, country, city — the last two unlock location segments) and to retroactively attribute their prior browsing. After that, just keep the email on your events.

⚠️

Never put PII on tocadule.cart(...). Cart state is PII-free by design — it carries the anonymous_id and resolves to the contact through the identity map. Any other event carrying the shopper's email links the cart automatically.

Custom events

track accepts any event name. A custom event is inserted just like a standard one, with all the same auto-enrichment (device, geo, UTM, click IDs, identity resolution) applied.

tocadule.track('size_guide_opened', { product_id: '101', size_chart: 'shoes-eu' });
tocadule.track('wishlist_add', { product_id: '250' });

Custom events appear in your dashboard's Events catalog alongside the standard ones — grouped by name with total fires, unique contacts, and last-fired time — and their properties are available for building segments and automation triggers. What a custom event does not get is the built-in forwarding map that standard commerce events (product_view, add_to_cart, checkout_started, purchase, search) have. If you need a custom event to reach an ad platform, map it explicitly in your destination settings.

Prefer a standard name when one fits — product_view, add_to_cart, checkout_started, purchase, view_cart, view_item_list, search — so the event automatically flows into cart intelligence, abandonment automations, and ad-platform forwarding without extra mapping.

Consent — handled for you

Every event carries two independent consent lanes, both auto-detected by the snippet from your CMP (Google Consent Mode, IAB TCF, OneTrust, Cookiebot, Iubenda, GPC, …):

  • consent_state — the analytics lane. Gates storing the anonymous_id and PII. We store unless it's explicitly denied.
  • ad_consent — the advertising lane. Gates whether an event is forwarded to ad destinations (full / limited / modeled).

You don't set these on track. If you have no CMP, you can drive them directly with tocadule.consent.set('granted'|'denied') and tocadule.consent.setAd('granted'|'denied'). Email/SMS opt-in (subscribed_email / subscribed_sms) is a separate merchant preference set via identify() or your preference center — it is not a CMP lane.