Browser snippet
One script tag on your site and page views start arriving — no build step, no package to install, no runtime dependencies. Everything it does, it does through the same public events endpoint you could call yourself; the snippet is just the part nobody enjoys writing twice: an anonymous id that survives a reload, batching, and a flush that outlives the tab.
Install
Paste this into your <head> with your project's API key:
<script defer src="https://tidingshq.com/js/v1/tidings.js" data-key="td_your_key"></script>The file is versioned and cached for an hour. It self-starts from the data-key attribute and exposes window.tidings. The key is an ingestion key: it can only write events, never read them, which is why it is safe in public HTML.
What it sends automatically
A page_view event on load and on every in-app route change, with these properties:
| Property | Value |
|---|---|
| path | location.pathname — the query string is deliberately left out, so ids and tokens in URLs never become event data. |
| host | location.hostname — so one project can carry a marketing site and an app subdomain without merging / on both into one page. |
| title | The document title. |
| referrer | document.referrer, omitted when there isn't one. |
| utm_source | …and utm_medium, utm_campaign, utm_term, utm_content — each present only when the URL carries it. |
Every event carries a distinct_id. Before anyone signs in that is a random anon_<uuid>, stored in localStorage with a 13-month cookie as a fallback, so a returning visitor is recognised as the same person rather than a new one. Read it with tidings.getDistinctId().
Custom events
Everything past page views is one call. The second argument is free JSON — whatever you will want to filter and break down by later:
tidings.track("signup_completed", { plan: "pro" });The call returns immediately and never throws — an ad blocker, a blocked cookie jar or an offline network are all swallowed silently. Analytics that can break a checkout is worse than analytics you lose.
Sign-in & sign-out
Call identify once the visitor becomes a known user:
tidings.identify("user_42");
// optional profile traits
tidings.identify("user_42", { plan: "pro", company: "Harborlight" });The first time a given anonymous id meets a given user id, the snippet posts one alias and everything that browser did before the sign-in — the ad click, the pricing page, the trial — is rewritten onto the account. From then on events carry the user id directly. It remembers the pair, so a reload or a second call posts nothing; you can safely call it on every page load while a session is active.
Use an id that is stable and not a secret: your internal user id is ideal. It appears in the Tidings UI and in exports, so an email address works but shares more than you may want.
On sign-out, call tidings.reset(). It drops the account id and mints a fresh anonymous one, so the next person at a shared browser doesn't file their events under the account that just left.
Options
Set as attributes on the script tag:
| Attribute | Default | Notes |
|---|---|---|
| data-key | — | Required. Without it the snippet loads and does nothing at all. |
| data-api-url | https://api.tidingshq.com | Point at your own deployment if you self-host. |
| data-auto-pageviews | true | Set to false to send no page views and drive them yourself. |
| data-heatmaps | false | Set to "true" to record where people click and how far they scroll — see Heatmaps. |
| data-heatmaps-text | on | Set to "off" to capture no element labels at all with clicks. Recommended behind a sign-in, where the text on screen is somebody's data. |
| data-cookie-domain | host-only | ".example.com" writes the id cookie on the parent domain, so a visit to your marketing site and the signed-in app on a subdomain are one visitor rather than two. Set it on both surfaces. |
| data-consent | notrequired | "required" holds back all storage and all events until tidings.consent(true); "granted" is what a banner writes into the tag once the visitor has agreed. See Consent. |
Every one of these is also an argument to tidings.init() when you would rather start the client from code — load the file without a data-key and call tidings.init({ key, apiUrl, heatmaps, … }) yourself, in camelCase (cookieDomain, heatmapsText). init is idempotent; a second call is ignored.
One option exists only there: beforeSend, a function that sees every event on its way out and can rewrite it or return null to drop it. There is no attribute form because there is no way to write a function in an HTML attribute that we would be willing to run.
tidings.init({
key: "td_your_key",
heatmaps: true,
beforeSend: (event) => {
event.properties.source = "app";
return event;
},
});Heatmaps
Off unless you ask for it. With data-heatmaps="true" the snippet also records where on a page people clicked and how far down it they read, and Insights → Heatmaps draws the result per page and per device band:
<script defer src="https://tidingshq.com/js/v1/tidings.js" data-key="td_your_key" data-heatmaps="true"></script>It adds two events. A $click on the primary mouse button, at most 50 per page view — a cap, not a sample, so an ordinary page is recorded whole and a click-storm on a canvas app cannot flood your project:
| Property | Value |
|---|---|
| x, y | Where the click landed. x is a 0–1 fraction of the document width (a page reflows, so only the horizontal share is comparable across viewports); y is the pixel offset from the top of the page, which does mean the same thing on a phone and a monitor. |
| vw, vh, dh | Viewport width and height, and the document height. vw is what the device bands — mobile, tablet, desktop — are read off. |
| tag, sel | The element's tag name, and a short selector: the element plus up to three ancestors, each as tag#id or tag.class.class. Class names with digits or over 24 characters are skipped — they are hashed build output and change on the next deploy. |
| txt, aria, role | The element's visible text (collapsed, 40 characters), its aria-label, and an explicit role — each present only when there is one, and subject to the masking rules below. This is what turns a hotspot into a row that says Start free trial. |
| href | Host and pathname of the nearest enclosing link, without its query string. |
And one $scroll per view, sent when the view ends — a route change, or the page being hidden — carrying depth (the deepest fraction of the page reached, 0–1), dwell_ms (how long the view lasted, capped at six hours) and the same vw, vh, dh. Both events carry path and host, so a heatmap is always of one page on one site.
What is never recorded
- No input values, ever. A click on a password field is ignored outright, and
txtis never taken from aninput,textarea,selector anything inside[contenteditable]. The click position is still recorded; what was typed is not. data-tidings-mask. Put it on any element and nothing inside it yields a label — the clicks still count, they just arrive anonymous. Use it on order numbers, names, anything a screenshot of that region would embarrass you.- Patterns that look personal. A label matching an email address, or containing six or more consecutive digits — an id, a phone number, a card — is dropped rather than truncated.
data-heatmaps-text="off"for app surfaces. Behind a sign-in the text on screen is your customer's data, not your copy. This dropstxtandariaentirely, leaving positions and selectors, which is all the picture needs.
Both names begin with $, which is how Tidings marks its own instrumentation: a $-prefixed event counts nowhere unless you ask for it by name. It stays out of Schema, Paths, funnels, sessions and top-event lists, so turning capture on never reshapes the numbers you already read — fifty clicks a view would otherwise bury every product event you have. The explorer still streams them, and a filter or a funnel step naming $click still finds them.
Previews: the page under the dots
A cloud of dots is only half an answer, so the heatmap is drawn over the page itself. There are two ways to get one, because a public marketing page and a screen behind a login are different problems.
Server-side screenshots, for public pages. Our own headless browser opens the page and stores a full-page JPEG at three viewport widths — 1440, 834 and 390 CSS pixels, the desktop, tablet and mobile bands. It will only fetch a host your own visitors have already reported page views from in the last 30 days, only over https, only at publicly routable addresses (a private, loopback or cloud-metadata address is refused), and never with cookies or a query string — so what it captures is the page a first-time, signed-out visitor sees.
You do not have to ask for the pages that matter most. As soon as clicks start arriving, the ten busiest pages of the past week are queued for capture on their own, at the desktop and mobile widths, and re-shot about weekly so a redesign does not leave last quarter's layout under this week's dots. Everything else is one press of Capture on the Heatmaps page — and Refresh re-shoots a page you have just changed without waiting for the weekly pass.
Heatmaps open on desktop, and the all-devices view is drawn over the desktop screenshot as well. A click is stored as a fraction of the document width rather than a pixel offset, so it lands in the right place on any width — one layout with every device's clicks on it is worth more than no picture at all, as long as you read phone taps on it knowing they crowd toward the centre of a narrower page. Pick a single device whenever that matters.
The on-site overlay, for everything else. "Open on site" mints a token that is good for ten minutes and one page, and opens https://your-site/page?tidings_heatmap=<token> in a new tab. The snippet already on that page strips the parameter from the URL, loads tidings-overlay.js, and paints the same heatmap over the live page — signed in, in whatever state you reached it. Capture is suspended while the overlay is up, so looking at a heatmap never becomes part of one.
Delivery
Events are queued, not sent one by one. A batch goes out when 20 events are waiting, two seconds after the first one was queued, or immediately when the page is hidden or unloaded — whichever comes first. So a visitor who reads one page and closes the tab still counts.
That last flush uses fetch(…, { keepalive: true }) rather than navigator.sendBeacon: the API key travels in an X-API-Key header, and sendBeacon cannot set headers. A request that fails is retried exactly once and then dropped — the snippet never grows an unbounded queue in someone else's tab.
SPA frameworks
Nothing to wire up. The snippet wraps history.pushState and replaceState and listens for popstate, so React Router, Next.js, Vue Router and everything else that navigates through the History API emit page views on their own. Only a change of path counts: a framework that replaces state on every render won't inflate your numbers.
If you want the page view to fire at a specific moment instead — after a route transition finishes, say, or with the resolved route pattern rather than the raw path — turn the automatic ones off with data-auto-pageviews="false" and send your own:
tidings.track("page_view", { path: route.path });Privacy
- Nothing is sent without a key. With no
data-keythe snippet writes no storage and makes no request — loading it is inert, which is what makes it safe to ship behind a consent gate. - One id, in localStorage and a cookie. The
tidings_anon_idvalue is a random UUID on your own origin, readable by nobody else. Where that needs consent first, run the snippet withdata-consent="required"— see Consent & privacy. - No fingerprinting. No canvas probing, no font or plugin enumeration, no cross-site identifier. If storage is unavailable the id lives in memory for the tab and is then gone.
- You choose the payload. Beyond path, host, title, referrer and UTM tags — and, only with heatmaps on, click positions and element labels — every property is one you passed in. Don't pass what you don't want stored.
Consent & privacy
Tidings is first-party analytics: the data is yours, it is collected on your own domain, and nothing about a visitor leaves it for an ad network. Concretely — no IP address is stored, there is no fingerprinting of any kind, nothing is shared with or sold to third parties, and events are deleted on the retention window you set per project. What is left is one random id in first-party storage, which is what makes a returning visitor a returning visitor rather than a new one.
Whether that needs a banner is a question about your site, your jurisdiction and your own legal advice, not one this page can answer. Both answers are supported.
Consent mode
Add data-consent="required" to the tag and the snippet starts inert: it reads and writes no storage, mints no id and sends no events. Events raised before consent are dropped rather than queued — a visitor who declined should leave no trace, not a backlog waiting for a change of mind. Then call it from your banner:
// the visitor accepted: storage is released and the
// page view that was held back goes out now
tidings.consent(true);
// they declined, or withdrew later: the stored id is
// erased and nothing further is sent
tidings.consent(false);consent(false) works in every mode, not only in required: it clears the id from localStorage and the cookie and stops. And data-consent="granted" behaves like the default — it exists so a banner that renders the tag server-side can say what the visitor already decided.
Global Privacy Control
When the browser sends navigator.globalPrivacyControl, the snippet honours it without being asked: the id never leaves memory — no localStorage, no cookie, so the visit counts but nothing carries to the next page — and heatmap capture stays off however the tag is configured. There is no option to turn this off. Those page views are marked gpc: true, so the Heatmaps page can tell you that a browser asked not to be measured rather than leaving you to read the missing clicks as a broken install.
Id lifetime
tidings_anon_id is stored with the date it was first seen and retired 13 months later, when a fresh id is minted; the cookie fallback carries the same 13-month Max-Age. Thirteen months is the longest an analytics id should outlive its visitor — long enough that a year-over-year return still recognises them, short enough that a browser nobody has used since is not still identified.
For your privacy statement
A starting point to adapt — it is a description of what the snippet does, not legal advice, and you are the one who knows what you have switched on:
"We use Tidings for analytics. It is a first-party service: the data it collects is stored for us alone and is never sold, shared with advertisers, or combined with data from other sites. It records which pages you visit, the site that referred you, and — where we have enabled it — where on a page visitors click and how far they scroll, which we use to see which parts of a page people actually use. It does not store your IP address and does not fingerprint your device. To tell a returning visitor from a new one it stores a random identifier in your browser, described below. We keep this data for [retention period] and then delete it."
…and the row for your cookie table:
| Name | Type | Duration | Purpose |
|---|---|---|---|
| tidings_anon_id | Analytics (first-party) | 13 months | A random identifier that distinguishes a returning visitor from a new one. Contains nothing about you and is readable only by this site. |
The same value is kept in localStorage alongside its first-seen stamp (tidings_anon_id_ts); the cookie is the fallback for browsers where localStorage is unavailable. If you call identify, your own user id is stored the same way under tidings_distinct_id.