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/eventwith 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:
| Key | What it is | Lives |
|---|---|---|
crm_vid | Visitor id | Until the visitor clears site data |
crm_sid | Session id | A new one starts after 30 minutes of inactivity |
crm_sat | Time of the last activity — used to decide when a session is over | Updated 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-spa | What 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. |
<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.
Consent mode
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,_fbpand_fbcstay where they are; - no ids are created.
crm_vid,crm_sidandcrm_satare 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.
Passing the answer on: crmConsent
Once widget.js has loaded, window.crmConsent is all your banner needs:
crmConsent.grant()functioncrmConsent.deny()functioncrm_vid, crm_sid and crm_sat. Works even without data-consent — see below.crmConsent.status()() => 'granted' | 'denied' | 'pending'pending — nobody has answered yet.crmConsent.requiredbooleantrue if the tag has data-consent="required".crmConsent.gpcbooleantrue 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:
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>// app/ConsentBanner.tsx — render it once in app/layout.tsx
'use client';
import Script from 'next/script';
import { useEffect, useState } from 'react';
type Choice = 'grant' | 'deny';
type Status = 'granted' | 'denied' | 'pending';
type ConsentApi = { push(choice: Choice): void; status(): Status };
declare global {
interface Window {
crmConsent?: ConsentApi | Choice[]; // an array until widget.js has loaded
}
}
export function ConsentBanner() {
const [status, setStatus] = useState<Status | null>(null); // null = widget not loaded yet
const sync = () => {
const api = window.crmConsent;
if (api && !Array.isArray(api)) setStatus(api.status());
};
useEffect(() => {
sync(); // the widget may already be there
window.addEventListener('crm:consent', sync);
return () => window.removeEventListener('crm:consent', sync);
}, []);
const choose = (choice: Choice) => {
window.crmConsent = window.crmConsent || [];
window.crmConsent.push(choice);
setStatus(choice === 'grant' ? 'granted' : 'denied');
};
return (
<>
<Script src="https://widget.sitecog.com/widget.js" data-consent="required" strategy="afterInteractive" onLoad={sync} />
{status === 'pending' && (
<div className="cookie-banner" role="dialog" aria-label="Cookies">
<p>We count visits to see which pages help and which ads work. Fine with you?</p>
<button onClick={() => choose('grant')}>Accept</button>
<button onClick={() => choose('deny')}>Reject</button>
</div>
)}
</>
);
}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:
<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.
https://shop.example/reset?token=9f2c41&utm_source=newsletter&code=AUTUMN10#step-2
→ https://shop.example/reset?utm_source=newsletter&code=AUTUMN10Page 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.
Ad links: know which ad brought the visitor
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=k3Zp9QaW1xdl 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.
Open Marketing → Events and click “Create event”
You need a site (domain) selected at the top.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.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.Money? Tick “This event brings revenue”
And set the currency name (EUR,USD,USDT, even your own loyalty points). Without this tick the event'svalueis not stored.Copy the ready-made call
The CRM shows the exactcrmTrackcall 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
window.crmTrack(
name: string,
params?: Record<string, string | number | boolean>,
options?: { id?: string; value?: number; currency?: string },
): voidIt 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
namestringrequiredparamsobjectoptionalDefault: {}{ 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: noneid twice and the event is stored once. Use your order id for purchases.options.valueintegeroptionalDefault: none149900 means 1499.00. From 0 to 1012. Stored only if the event has “This event brings revenue” on.options.currencystringoptionalDefault: the event’s currencyEUR, 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>// diil.d.ts — teach TypeScript about the widget once
export {};
type CrmParams = Record<string, string | number | boolean>;
type CrmOptions = { id?: string; value?: number; currency?: string };
declare global {
interface Window {
crmTrack?: (name: string, params?: CrmParams, options?: CrmOptions) => void;
crmq?: unknown[];
}
}
// AddToCartButton.tsx
type Product = { sku: string; price: number };
export function AddToCartButton({ product, onAdd }: { product: Product; onAdd: () => void }) {
const handleClick = () => {
onAdd();
window.crmTrack?.('add_to_cart', { sku: product.sku, price: product.price });
};
return <button onClick={handleClick}>Add to cart</button>;
}// app/checkout/success/PurchaseTracker.tsx
'use client';
import { useEffect } from 'react';
type Props = { orderId: string; items: number; totalCents: number; currency: string };
export function PurchaseTracker({ orderId, items, totalCents, currency }: Props) {
useEffect(() => {
// The effect may run before widget.js has loaded — the queue waits for it
window.crmq = window.crmq || [];
window.crmq.push([
'purchase',
{ order_id: orderId, items },
{ id: orderId, value: totalCents, currency },
]);
}, [orderId, items, totalCents, currency]);
return null;
}
// app/checkout/success/page.tsx (a Server Component)
// <PurchaseTracker orderId="A-1024" items={2} totalCents={29800} currency="EUR" />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:
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.
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 type | Accepts | Good to know |
|---|---|---|
| string | Any value | Trimmed to 500 characters. Objects are turned into JSON strings — but flat values are much nicer in reports. |
| number | Finite numbers up to 1012 in absolute value | Rounded to 4 decimal places. |
| boolean | true, false, "true", "false", 1, 0 | Handy 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
valueends upnull.
const total = 298.0; // what your cart shows
const value = Math.round(total * 100); // 29800 — what Diil wantsid: 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-keyheaderstringrequiredsk_….namestringrequiredn.event_idstringoptionalduplicate: true and not stored again. Alias: id.visitor_idstringoptionalcrm_vid from the browser. Alias: vid.session_idstringoptionalcrm_sid. With it, the event inherits the channel, UTM tags and ad link of that session. Alias: sid.paramsobjectoptionalp.valueintegeroptionalval.currencystringoptionalcur.urlstringoptionalu.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;
}<?php
function send_purchase(array $order): bool
{
$payload = [
'name' => 'purchase',
'event_id' => $order['id'], // "A-1024" — retries are safe
'visitor_id' => $order['crm_vid'], // saved at checkout, may be null
'session_id' => $order['crm_sid'],
'params' => ['order_id' => $order['id'], 'items' => count($order['items'])],
'value' => $order['total_cents'], // 29800 = 298.00
'currency' => 'EUR',
'url' => 'https://shop.example/checkout',
];
$ch = curl_init('https://back.sitecog.com/marketing/event');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-event-key: ' . getenv('DIIL_EVENT_KEY'),
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
error_log("Diil event failed: $status $body");
return false;
}
return true;
}curl -X POST https://back.sitecog.com/marketing/event \
-H "content-type: application/json" \
-H "x-event-key: $DIIL_EVENT_KEY" \
-d '{
"name": "purchase",
"event_id": "A-1024",
"visitor_id": "<crm_vid>",
"session_id": "<crm_sid>",
"params": { "order_id": "A-1024", "items": 2 },
"value": 29800,
"currency": "EUR",
"url": "https://shop.example/checkout"
}'Response
{ "ok": true, "duplicate": false }{ "ok": true, "duplicate": true }okbooleanduplicatebooleanevent_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:
| Status | Body | What 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. |
Link server events to the visitor
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:
- at checkout, read
crm_vidandcrm_sidfromlocalStorage; - send them to your backend with the order and store them next to it;
- when the payment is confirmed, pass them as
visitor_idandsession_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() }),
});// server.js (Express)
app.post('/api/orders', async (req, res) => {
const { cart, crm_vid, crm_sid } = req.body;
// Keep the ids with the order: payment is often confirmed later, by a webhook
const order = await db.orders.create({ cart, crmVid: crm_vid, crmSid: crm_sid });
res.json({ id: order.id, payUrl: await createPayment(order) });
});
// Your payment provider calls this when the money has actually arrived
app.post('/webhooks/payment', async (req, res) => {
const order = await db.orders.markPaid(req.body.orderId);
// 3 · Diil — never let analytics break the checkout
try {
await sendPurchase(order); // the function from "Server events" above
} catch (err) {
console.error('Diil is unreachable, will retry later', err);
}
res.sendStatus(200);
});Limits
The limits are shared by all marketing routes — page views, events and leads together — and counted in fixed one-minute windows.
| What | Limit |
|---|---|
| Requests per IP | 120 per minute |
| Requests per site | 6,000 per minute |
| Request body | 8 KB (larger → 413) |
| Active event types per site | 100 |
| Parameters per event | 20 |
| Event name / parameter key | 64 / 40 characters |
| String parameter value | 500 characters |
id / event_id | 128 characters |
| Active secret keys per site | 5 |
Troubleshooting
The event doesn't show up
- It isn't declared. The browser endpoint always answers
204and silently drops unknown event names — no hints, by design. The server endpoint is honest:400 unknown_eventwith the name you sent. When in doubt, send the same event once with curl and read the answer. - The name differs.
addToCartin code andadd_to_cartin 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
crmTrackwas 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 gets400 unknown_domain— look for it in the DevTools Network tab. - Your CSP blocks it. Allow
https://widget.sitecog.cominscript-srcandhttps://back.sitecog.cominconnect-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. Notclick1, notButtonPressed. - Name the outcome, not the widget.
signup_completedsurvives a redesign;green_button_clickdoes not. - One event, many params.
add_to_cartwith{ sku, price }beatsadd_to_cart_air3,add_to_cart_air4… — and keeps you far from the 100-type limit. - Always pass
idfor 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.