Diil Docs
  1. Docs
  2. The widget

Events and analytics: page views, custom events, conversions

Updated:

The widget.js tag you already added for live editing quietly counts every page view, works out where the visitor came from and which ad brought them. Add one line of JavaScript — or one request from your server — and you also see who added to cart, who signed up and who actually paid, with the money attached. No second analytics script, no tag manager, no cookie banner rewrite.

Here is the menu:

  • Page views — automatic, zero code. Channels, UTM tags, ad click ids, geo, devices.
  • Custom events from the browser — window.crmTrack('add_to_cart', …) for UX signals.
  • Server events — POST /marketing/event with a secret key, for purchases and anything else that must be counted exactly once.

Everything lands in the CRM under Marketing: Traffic for visits, Events for your own events, Ad channels for tagged links.

What you get out of the box: page views

If widget.js is on the page, page views are already being counted. The tag is the same one from Live editing:

<script src="https://widget.sitecog.com/widget.js" defer></script>

On every page load the widget sends one page view with navigator.sendBeacon — a tiny fire-and-forget request that does not slow the page down and survives the visitor closing the tab. The server answers 204 No Content; there is nothing to read back.

What is collected

From the browser:

  • page URL, title and referrer;
  • UTM tags: utm_source, utm_medium, utm_campaign, utm_term, utm_content;
  • ad click ids: gclid, gbraid, wbraid (Google), yclid (Yandex), fbclid (Meta), msclkid (Microsoft);
  • the ad-link code ?dl= from Ad channels links;
  • a few ad cookies, if your site already has them: _ga, _gcl_*, _ym_uid, _fbp, _fbc — in consent mode not before the visitor consents, and never if the browser sends Global Privacy Control;
  • screen and viewport size, pixel ratio, browser language and timezone.

Added on our side:

  • location, from the IP address;
  • device type, browser and operating system;
  • traffic channel: paid, social, organic, referral, email or direct;
  • new or returning visitor.

Before anything is saved, the IP address is truncated and URLs lose their #fragment and sensitive parameters like token or email — details in What we store and for how long.

Bots are not thrown away — they are flagged and filtered out of reports. So the numbers you look at are about people, and the raw data is still there if you ever wonder how much of your traffic is crawlers (spoiler: more than you'd like).

The reports live in Marketing → Traffic: visitors, sessions, channels, pages, geography and devices.

Days and hours in reports

“Yesterday” in a report is yesterday in your site's time zone, not in UTC and not in whatever zone the person looking at the chart happens to be in. A shop in Kyiv sees its evening rush at 8 p.m., not at 5 p.m., and an owner in Kyiv and a manager in New York look at the same days.

  • The zone is set in CRM → Settings → Time zone. Until someone picks one, the CRM takes the time zone of the owner's browser.
  • Days in Traffic, Ad channels and Events, and the hours on charts, all follow it.
  • Changing the zone recalculates past reports too. The data itself doesn't change — only where midnight falls.

Visitor and session ids

The widget recognises a returning visitor without cookies. It keeps three keys in localStorage:

KeyWhat it isLives
crm_vidVisitor idUntil the visitor clears site data
crm_sidSession idA new one starts after 30 minutes of inactivity
crm_satTime of the last activity — used to decide when a session is overUpdated as the visitor browses

If storage is blocked (some privacy modes do that), the ids live in memory for as long as the tab is open. Remember crm_vid and crm_sid — you will need them to link server events to the visitor. In consent mode these keys appear only after the visitor consents, and crmConsent.deny() erases them.

Single-page apps

Client-side navigation is counted automatically. The widget listens to history.pushState, history.replaceState and the popstate event — the plumbing React Router, Next.js and Vue Router use under the hood — and sends a page view whenever the path or the query string changes. It waits 300 ms first, so a burst of quick URL updates turns into a single page view rather than a handful.

The referrer of such a “virtual” page view is the previous page of your site, so the route a visitor took through the app reads naturally in the reports. If you want to tune it, there is one attribute on the tag:

data-spaWhat is counted
not set (default)Path and query string changes. Changes of the #hash alone are not page views.
"hash"Hash changes count too — for hash routers with URLs like /#/pricing.
"off"SPA tracking is off: one page view per script load, the way it used to be.
A hash-router apphtml
<script src="https://widget.sitecog.com/widget.js" data-spa="hash" defer></script>

Privacy and consent

The widget sets no cookies of its own. Still, plenty of sites must ask before they count anything — and you don't have to juggle script loading for that. Switch on consent mode, connect your cookie banner to it, and the widget waits for the visitor's answer while forms and chat keep working.

Add data-consent="required" to the tag:

<script src="https://widget.sitecog.com/widget.js" data-consent="required" defer></script>

Until the visitor consents:

  • no page views and no events are sent. They are dropped, not queued — nothing from before the consent is sent later;
  • cookies are not read. The widget doesn't touch document.cookie, so _ga, _gcl_*, _ym_uid, _fbp and _fbc stay where they are;
  • no ids are created. crm_vid, crm_sid and crm_sat are neither created nor stored.

Lead forms and the support chat work before consent too — a visitor who wants to ask something shouldn't have to accept cookies first. They just arrive without visitor and session ids, which means without attribution: no channel, no UTM tags, no ad link on that lead or chat.

Without the attribute nothing changes: counting starts right away, exactly as before.

Once widget.js has loaded, window.crmConsent is all your banner needs:

crmConsent.grant()function
The visitor agreed. Counting starts, and a page view for the current page is sent right away (once) — so the visit where they clicked “Accept” isn't lost.
crmConsent.deny()function
The visitor declined. Erases crm_vid, crm_sid and crm_sat. Works even without data-consent — see below.
crmConsent.status()() => 'granted' | 'denied' | 'pending'
The current choice. pending — nobody has answered yet.
crmConsent.requiredboolean
true if the tag has data-consent="required".
crmConsent.gpcboolean
true if the browser sends Global Privacy Control.

The choice is kept in localStorage under crm_consent, so the widget remembers it on the next visits. Your banner, on the other hand, may well be clicked before widget.js has loaded. Same trick as with crmq — a queue:

Works before and after widget.js loadsjs
window.crmConsent = window.crmConsent || [];
crmConsent.push('grant');   // or 'deny'

push keeps working after the script has loaded, so a banner can simply always use it. Whenever the choice changes, the widget fires a crm:consent event on window — read the new state with status() in the handler:

window.addEventListener('crm:consent', () => {
  console.log('Consent is now', window.crmConsent.status());
});

A complete banner: show it only while the answer is pending, pass the click on, hide it.

<div id="consent-banner" class="cookie-banner" hidden>
  <p>We count visits to see which pages help and which ads work. Fine with you?</p>
  <button type="button" data-choice="grant">Accept</button>
  <button type="button" data-choice="deny">Reject</button>
</div>

<script>
  window.crmConsent = window.crmConsent || [];
  const banner = document.getElementById('consent-banner');

  banner.addEventListener('click', (e) => {
    const choice = e.target.dataset.choice;
    if (!choice) return;
    crmConsent.push(choice); // 'grant' or 'deny' — before or after widget.js loads
    banner.hidden = true;
  });

  // Deferred scripts run before DOMContentLoaded, so the real API is here by now.
  // widget.js blocked? Then crmConsent is still an array and there is nothing to ask about.
  document.addEventListener('DOMContentLoaded', () => {
    banner.hidden = window.crmConsent.status?.() !== 'pending';
  });
</script>

<script src="https://widget.sitecog.com/widget.js" data-consent="required" defer></script>

Global Privacy Control and opting out

Some browsers and privacy extensions send Global Privacy Control (navigator.globalPrivacyControl) — the visitor's standing “please don't”. The widget honours it in any mode, with or without data-consent: if the browser sends GPC, ad cookies are never read. crmConsent.gpc tells you whether that is the case.

Opting out works everywhere, too: crmConsent.deny() is honoured even without the data-consent attribute. So a site that counts from the first page can still offer a “Do not track me” link in the footer:

Footerhtml
<a href="#" id="dont-track">Do not track me</a>

<script>
  document.getElementById('dont-track').addEventListener('click', (e) => {
    e.preventDefault();
    window.crmConsent = window.crmConsent || [];
    crmConsent.push('deny'); // also erases crm_vid / crm_sid / crm_sat
    e.currentTarget.textContent = 'Done — you are opted out';
  });
</script>

What we store and for how long

Analytics needs to know where visitors come from — not their street address or their password reset link. So a few things are trimmed before anything is saved.

IP addresses are truncated. IPv4 loses its last number (203.0.113.57 → 203.0.113.0), IPv6 is cut to /48. Country and city are worked out from the full address before it is truncated, so the geography reports work just as well.

URLs are cleaned. Page URLs, referrers, the landing URL and event URLs lose their #fragment and these query parameters:

token *_token access_token id_token refresh_token auth_token auth password pass passwd pwd email e-mail mail key api_key apikey secret client_secret otp session sessionid jwt

*_token means any name ending in _token. code is kept on purpose — promo codes are useful in reports — and so are UTM tags, ad click ids and dl.

Before and aftertext
https://shop.example/reset?token=9f2c41&utm_source=newsletter&code=AUTUMN10#step-2
→ https://shop.example/reset?utm_source=newsletter&code=AUTUMN10

Page views and events are kept for 13 months (395 days) and then deleted. The operator of the installation can change that period. Leads are not affected — this clean-up never deletes them.

A visitor can ask to be forgotten sooner. The site owner deletes their profile, page views and events by visitor id straight from the CRM; leads go only if that is chosen separately. Details in Leads → Deleting a person's data.

In Marketing → Ad channels you create tagged links for each place you advertise — a social network post, a newsletter, a banner on someone else's site. Each link points to your site and carries UTM tags plus a short code:

https://shop.example/?utm_source=instagram&utm_medium=social&utm_campaign=autumn_sale&dl=k3Zp9QaW1x

dl is a 10-character code that identifies the link. You don't need to do anything on the site: the widget picks it up with the first page view, and every visit, event and lead in that session is attributed to the link. Each link gets its own statistics in the CRM.

Custom events

Page views tell you where people went. Events tell you what they did: added to cart, signed up, opened the pricing calculator, paid. You describe an event once in the CRM, then send it from the browser or from your server.

Step zero: declare the event in the CRM

Diil only accepts events it knows about. That is a feature: a typo in your code can't quietly create a new event type and split your reports in two.

  1. Open Marketing → Events and click “Create event”

    You need a site (domain) selected at the top.
  2. Name it exactly as it appears in code

    add_to_cart, signup_completed, purchase. Latin letters, digits and underscores, starting with a letter, up to 64 characters. Add a human title and a description — future you will thank you.
  3. Describe the parameters

    Up to 20 per event, each with a type: string, number or yes/no (boolean). Anything not described here will not reach the database.
  4. Money? Tick “This event brings revenue”

    And set the currency name (EUR, USD, USDT, even your own loyalty points). Without this tick the event's value is not stored.
  5. Copy the ready-made call

    The CRM shows the exact crmTrack call and the server request for this event. Paste and go.

A site can have up to 100 active event types. Once an event has data, its name is locked (it is already in your code) and the event can be archived rather than deleted, so reports never end up with nameless rows.

Send events from the browser: crmTrack

Signaturets
window.crmTrack(
  name: string,
  params?: Record<string, string | number | boolean>,
  options?: { id?: string; value?: number; currency?: string },
): void

It is fire-and-forget: returns nothing, never throws at you, and sends with sendBeacon, so the event survives even if the click navigates away from the page. The visitor and session ids are attached automatically — you only describe what happened.

Arguments

namestringrequired
The event name as declared in the CRM. Latin letters, digits and underscores, starts with a letter, up to 64 characters. An undeclared name is silently dropped — see Troubleshooting.
paramsobjectoptionalDefault: {}
Flat object of event details: { sku: 'air3-graphite', price: 149 }. Keys follow the same rules as names, up to 40 characters. Only parameters described in the CRM are kept; values are converted to the declared type — see Parameters and types.
options.idstringoptionalDefault: none
Idempotency key, up to 128 characters, unique per site. Send the same id twice and the event is stored once. Use your order id for purchases.
options.valueintegeroptionalDefault: none
Amount in minor units: 149900 means 1499.00. From 0 to 1012. Stored only if the event has “This event brings revenue” on.
options.currencystringoptionalDefault: the event’s currency
Currency label, 1–10 Latin letters or digits: EUR, USD, UAH. Leave it out and the currency set on the event in the CRM is used.

Examples

The three events almost every shop needs — in whatever your site is made of:

<button id="buy" data-sku="air3-graphite" data-price="149">Add to cart</button>

<form id="signup">…</form>

<script>
  // ?. — so an ad blocker that ate widget.js doesn't break your button
  document.getElementById('buy').addEventListener('click', (e) => {
    const { sku, price } = e.currentTarget.dataset;
    window.crmTrack?.('add_to_cart', { sku, price: Number(price) });
  });

  // Call it when the account is really created, not on the first click
  function onSignupSuccess() {
    window.crmTrack?.('signup_completed', { method: 'email', newsletter: true });
  }

  // On the "thank you" page. This runs before the deferred widget.js,
  // so it goes through the queue (see below); id makes a reload harmless
  window.crmq = window.crmq || [];
  crmq.push([
    'purchase',
    { order_id: 'A-1024', items: 2 },
    { id: 'A-1024', value: 29800, currency: 'EUR' }, // 298.00 EUR
  ]);
</script>

Calling before the script loads: the crmq queue

widget.js is loaded with defer, so for a brief moment window.crmTrack doesn't exist yet. Events fired early — on page load, in an effect, from an inline script in the <head> — would be lost. The queue fixes that:

Works before and after widget.js loadsjs
window.crmq = window.crmq || [];
crmq.push(['purchase', { order_id: 'A-1024', items: 2 }, { id: 'A-1024', value: 29800, currency: 'EUR' }]);

Each item is an array of the same three arguments as crmTrack: name, params, options. When widget.js loads, it replays everything in the queue. After that, crmq.push sends immediately — so you can simply always use the queue and never think about load order.

A tiny helper that is always safe to callts
type CrmParams = Record<string, string | number | boolean>;
type CrmOptions = { id?: string; value?: number; currency?: string };

export function track(name: string, params?: CrmParams, options?: CrmOptions) {
  if (typeof window === 'undefined') return; // server render: nothing to do
  window.crmq = window.crmq || [];
  window.crmq.push([name, params || {}, options || {}]);
}

Parameters and types

Parameters are checked against the description in the CRM and normalised to the declared type by these rules:

Declared typeAcceptsGood to know
stringAny valueTrimmed to 500 characters. Objects are turned into JSON strings — but flat values are much nicer in reports.
numberFinite numbers up to 1012 in absolute valueRounded to 4 decimal places.
booleantrue, false, "true", "false", 1, 0Handy when the value comes from a data-* attribute.

Parameter keys: Latin letters, digits and underscores, starting with a letter, up to 40 characters. Up to 20 parameters per event.

Revenue and idempotency

value and currency

Money is sent as an integer in minor units: cents, kopecks, satoshi — whatever the smallest unit of your currency is. 29800 with EUR is 298.00 €. Fractional money in a database sooner or later produces penny discrepancies, so we simply don't allow it.

  • value: an integer from 0 to 1012.
  • currency: 1–10 Latin letters or digits (EUR, USD, UAH, USDT). If you leave it out, the currency set on the event is used.
  • Both are stored only if the event has This event brings revenue ticked; otherwise value ends up null.
const total = 298.0;                         // what your cart shows
const value = Math.round(total * 100);       // 29800 — what Diil wants

id: count it once

Thank-you pages get reloaded, opened from the history, shared to a second device. Pass an id (in the browser) or event_id (from the server) and Diil stores the event once no matter how many times it arrives. Up to 128 characters, unique per site. Your order number is the perfect candidate.

Server events: POST /marketing/event

For anything that involves money, the browser is not the place to count it. Ad blockers can block the request, people close the tab before the thank-you page loads, and anyone can call crmTrack('purchase') from the console. Your server, on the other hand, knows exactly when a payment is confirmed. Send the event from there:

POST https://back.sitecog.com/marketing/event
content-type: application/json
x-event-key: sk_…

Secret keys

Server events are signed with a secret key: Marketing → Events → Secret keys → Issue a key. It looks like sk_ + 48 hex characters. You can have up to 5 active keys per site — one per server or integration — and revoke any of them in the CRM. Leads sent from a server use the same keys.

Request

x-event-keyheaderstringrequired
Your secret key, sk_….
namestringrequired
Event name as declared in the CRM. Short alias: n.
event_idstringoptional
Idempotency key, up to 128 characters, unique per site. A repeat is answered with duplicate: true and not stored again. Alias: id.
visitor_idstringoptional
The visitor's crm_vid from the browser. Alias: vid.
session_idstringoptional
The visitor's crm_sid. With it, the event inherits the channel, UTM tags and ad link of that session. Alias: sid.
paramsobjectoptional
Event parameters, same rules as in the browser. Alias: p.
valueintegeroptional
Amount in minor units, 0 to 1012. Alias: val.
currencystringoptional
Currency label; defaults to the event's currency. Alias: cur.
urlstringoptional
The page the event relates to, e.g. your checkout. Alias: u.

The whole body must fit into 8 KB.

// Node 18+ — fetch is built in
export async function sendPurchase(order) {
  const res = await fetch('https://back.sitecog.com/marketing/event', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-event-key': process.env.DIIL_EVENT_KEY,
    },
    body: JSON.stringify({
      name: 'purchase',
      event_id: order.id,              // "A-1024" — retries are safe
      visitor_id: order.crmVid,        // saved at checkout, may be null
      session_id: order.crmSid,
      params: { order_id: order.id, items: order.items.length },
      value: order.totalCents,         // 29800 = 298.00
      currency: 'EUR',
      url: 'https://shop.example/checkout',
    }),
    signal: AbortSignal.timeout(5000),
  });

  if (!res.ok) {
    console.error('Diil event failed:', res.status, await res.text());
  }
  return res.ok;
}

Response

200 OKjson
{ "ok": true, "duplicate": false }
200 OK — the same event_id againjson
{ "ok": true, "duplicate": true }
okboolean
The event was accepted.
duplicateboolean
True if an event with this event_id already exists. Nothing new was stored — and that is fine, not an error.

Errors

Unlike the browser, the server route tells you exactly what went wrong:

StatusBodyWhat happened
400{"message":"invalid_body"}The body isn't valid JSON or isn't the expected object.
400{"message":"invalid_event_name"}The name breaks the format: Latin letters, digits, underscores, starts with a letter, up to 64 characters.
400{"message":"unknown_event","name":"purchse"}No such event in Marketing → Events. Typo, or not declared yet. The body echoes the name you sent.
401{"message":"invalid_key"}Missing, malformed or revoked x-event-key.
413—The body is larger than 8 KB.
429{"message":"rate_limit_exceeded"}Too many requests this minute. See Limits.

A server event on its own knows nothing about ads: your server has no idea the buyer came from an Instagram post three days ago. The browser does. So the trick is to carry the widget's ids from the browser to your backend together with the order:

  1. at checkout, read crm_vid and crm_sid from localStorage;
  2. send them to your backend with the order and store them next to it;
  3. when the payment is confirmed, pass them as visitor_id and session_id.

The event then inherits the channel, UTM tags and ad link of the session's first page view — so Marketing → Events shows which channel and which ad link brought the money, not just the clicks.

// checkout.js — when the customer presses "Pay"
function diilIds() {
  try {
    return {
      crm_vid: localStorage.getItem('crm_vid'),
      crm_sid: localStorage.getItem('crm_sid'),
    };
  } catch {
    return { crm_vid: null, crm_sid: null }; // storage blocked: the order still goes through
  }
}

const res = await fetch('/api/orders', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ cart, ...diilIds() }),
});

Limits

The limits are shared by all marketing routes — page views, events and leads together — and counted in fixed one-minute windows.

WhatLimit
Requests per IP120 per minute
Requests per site6,000 per minute
Request body8 KB (larger → 413)
Active event types per site100
Parameters per event20
Event name / parameter key64 / 40 characters
String parameter value500 characters
id / event_id128 characters
Active secret keys per site5

Troubleshooting

The event doesn't show up

  • It isn't declared. The browser endpoint always answers 204 and silently drops unknown event names — no hints, by design. The server endpoint is honest: 400 unknown_event with the name you sent. When in doubt, send the same event once with curl and read the answer.
  • The name differs. addToCart in code and add_to_cart in the CRM are two different things. Copy the call from the event card.
  • You are testing in Live mode. Inside the CRM iframe nothing is tracked. Use a normal tab.
  • Consent mode is on and there is no consent yet. If crmConsent.status() returns 'pending', page views and events are dropped — not postponed, so they won't show up later either. Accept your own banner and try again. See Consent mode.
  • The script didn't load or crmTrack was called too early. Use the crmq queue.
  • The domain isn't a site in the CRM. The site is recognised by the page's Origin (www. is ignored); an unknown one gets 400 unknown_domain — look for it in the DevTools Network tab.
  • Your CSP blocks it. Allow https://widget.sitecog.com in script-src and https://back.sitecog.com in connect-src.

The event is there, but parameters are missing

Open the event in Marketing → Events. If you see Arrives but is not described, the parameter reached us but isn't in the event's description — add it (or fix the typo in code). Also check that the values fit the declared types from Parameters and types.

The purchase has no amount

Tick This event brings revenue on the event, and send value as an integer in minor units — 29800, not 298.00 and not "298 €".

Numbers don't match the payment system

Some visitors run ad blockers or privacy extensions that block analytics requests, and some close the tab before the thank-you page. Browser events will always be a little short. That is fine for UX signals and not fine for money: send purchases from the server — it can't be blocked, it can't be faked from the console, and with event_id it is never counted twice.

Naming events: a few habits that pay off

  • snake_case, verb-ish, past or plain: add_to_cart, signup_completed, purchase, calculator_opened. Not click1, not ButtonPressed.
  • Name the outcome, not the widget. signup_completed survives a redesign; green_button_click does not.
  • One event, many params. add_to_cart with { sku, price } beats add_to_cart_air3, add_to_cart_air4… — and keeps you far from the 100-type limit.
  • Always pass id for anything that may fire twice: purchases, confirmations, one-time signups.
  • Browser for behaviour, server for money. Clicks and steps from crmTrack; payments, refunds and subscriptions from your backend.
  • Write the description in the CRM. “Fires when the payment provider confirms the charge” saves a meeting in six months.

Where to next