Tracking
warm.js is the client-side tracking script you embed on your site. It captures visitor behaviour, persists a first-party device identifier, and ships events to Warm AI’s edge function through a Cloudflare Worker proxy. This guide covers exactly what it does — payload shapes included — so you know precisely what you’re deploying.
Bundle size: ~4 KB minified.
Install
Add the following snippet to the <head> tag of every page:
<script src="https://assets.warmai.uk/warm.js" data-id="YOUR_TRACKING_ID" async></script>Replace YOUR_TRACKING_ID with the tracking_id returned when you create a website. The script initialises automatically on DOMContentLoaded (or immediately if the DOM is already ready).
Full public compliance disclosure (what’s collected, lawful basis, sub-processors, opt-out): getwarmai.com/tracker .
GTM template
If your site uses Google Tag Manager, use the official WarmAI GTM Community Template instead of pasting the snippet manually. The template exposes a Tracking ID field and handles the data-id attribute for you.
For API reference on creating and managing websites, see Websites API. That page also has a troubleshooting checklist for common install failures (WordPress smart quotes, WP Rocket, Rocket Loader, CSP headers).
What gets tracked
warm.js fires eight categories of event. All events share a common base payload and then add category-specific fields.
Common base payload
Every event — regardless of type — includes the following fields:
| Field | Type | Description |
|---|---|---|
tracking_script_id | string | Your tracking_id, from the data-id attribute |
event_type | string | One of the event names below |
session_token | string | UUID for this session (rotated on tab show after hide) |
device_id | string | UUID from the warm_device cookie (persistent, 1-year) |
url | string | Full location.href at time of event |
path | string | location.pathname |
title | string | document.title |
referrer | string | null | document.referrer |
user_agent | string | navigator.userAgent |
The Cloudflare Worker proxy appends the real client IP before forwarding to Supabase — the browser script never sends an IP field directly.
1. session_start
Fired on first page load (no existing session in sessionStorage) or when a hidden tab becomes visible again after a session_end.
{
"tracking_script_id": "trk_x9y8z7",
"event_type": "session_start",
"session_token": "a1b2c3d4-...",
"device_id": "f7e6d5c4-...",
"url": "https://yoursite.com/pricing?utm_source=google&utm_campaign=brand",
"path": "/pricing",
"title": "Pricing – YourSite",
"referrer": "https://google.com",
"user_agent": "Mozilla/5.0 ...",
"utm_source": "google",
"utm_medium": null,
"utm_campaign": "brand",
"utm_term": null,
"utm_content": null,
"first_touch_utm_source": "linkedin",
"first_touch_utm_medium": "paid",
"first_touch_utm_campaign": "warmup-q1"
}UTM fields are only included when present on the current URL. first_touch_utm_* fields are only included when a warm_first_touch cookie already exists from a prior visit.
2. page_view
Fired on each client-side navigation (SPA pushState / replaceState / popstate). Also fired as the first event on a returning session (tab that already had a session token in sessionStorage).
{
"event_type": "page_view",
"duration_seconds": 47,
"active_seconds": 32,
"scroll_depth": 78
}duration_seconds is wall-clock time on the previous path. active_seconds is the subset of that time where the tab was visible and the visitor interacted within the last 30 seconds (mousemove, keypress, touchstart, scroll). scroll_depth is the max scroll percentage reached on that path (0–100).
3. session_end
Fired on beforeunload or when the tab is hidden (visibilitychange → hidden). After a session_end, the next visibilitychange → visible starts a fresh session with a new session_token.
{
"event_type": "session_end",
"duration_seconds": 183,
"active_seconds": 94,
"scroll_depth": 100
}Persistent device identification
When warm.js loads for the first time on a domain, it reads the warm_device cookie. If none exists, it generates a fresh UUID and sets the cookie. On every subsequent visit — same browser, same device — the same UUID is read back and included in every event as device_id.
This is how Warm AI links sessions across days and weeks. The dashboard’s returning visitors count relies entirely on device_id continuity.
Cross-subdomain stitching
Before setting the cookie, warm.js probes for the widest domain it can write to by trying .example.com, then .com, and taking the first one that works. In practice this means a visitor who hits www.example.com and then blog.example.com shares the same warm_device value — the sessions are stitched together.
Cookie spec
| Name | Domain | TTL | SameSite | Value |
|---|---|---|---|---|
warm_device | Root domain (TLD probe) | 1 year | Lax | URL-encoded UUID v4 |
The cookie contains nothing beyond a random UUID. No personal data, no encoded user info.
Because warm_device is non-essential, UK and EU sites must gate warm.js behind a consent banner before it can set this cookie. See Cookies & Consent for jurisdiction requirements and CMP setup.
Active time vs page time
warm.js tracks two time measures per page:
duration_seconds— wall-clock time from navigation to the next navigation or session end. Includes time the tab was in the background, or the user was idle.active_seconds— time the tab was visible and the visitor interacted within the past 30 seconds (mousemove, keypress, touchstart, scroll). Actively polled every 5 seconds.
Active time is the more honest engagement signal. A visitor who opens your pricing page in a background tab for 20 minutes has 1200 duration_seconds but 0 active_seconds. A visitor who reads every paragraph has both numbers close together.
The Warm AI dashboard uses active_seconds for the engagement score shown on the Sessions view.
UTM attribution
Last-touch capture
On every session_start, warm.js reads utm_source, utm_medium, utm_campaign, utm_term, and utm_content from the current URL’s query string. These are included in the session_start payload as top-level fields. They represent last-touch attribution — whichever campaign drove this specific visit.
First-touch persistence
The first time a session starts with any UTM parameters present, warm.js writes a warm_first_touch cookie containing utm_source, utm_medium, and utm_campaign. The cookie has the same 1-year TTL and domain scope as warm_device.
On subsequent sessions, the warm_first_touch values are included in the session_start payload as first_touch_utm_source, first_touch_utm_medium, and first_touch_utm_campaign — even if the new session arrives with no UTMs (e.g. direct return visit).
Example: Visitor arrives via a LinkedIn paid campaign → first-touch cookie written. Two weeks later they return via Google → session_start carries both utm_source: "google" (last touch) and first_touch_utm_source: "linkedin" (first touch from the cookie).
Form engagement
warm.js listens to form interactions document-wide and fires four event types. These events work with any <form> element — no markup changes required.
Event types
| Event | Trigger | Notes |
|---|---|---|
form_start | First focusin to any field inside a <form> | Fired once per form per session |
form_update | change event on any form field | Fired per field change |
form_submit | submit event on a <form> | Marks the form as submitted (suppresses form_abandon) |
form_abandon | beforeunload with a started-but-not-submitted form | Fired for each incomplete form |
Payload fields
All form events include the base payload plus:
| Field | Type | Description |
|---|---|---|
form_id | string | The form’s id, name, data-form-id, or a derived key from the first input’s name |
field_label | string | null | Resolved from <label for="...">, aria-label, placeholder, or name in that order (max 80 chars) |
field_type | string | The input’s type attribute (e.g. "email", "text", "select-one") |
form_completeness | number | Percentage of non-hidden, non-submit fields with a non-empty value (0–100) |
Example form_start payload
{
"event_type": "form_start",
"form_id": "contact-form",
"field_label": "Work email",
"field_type": "email",
"form_completeness": 0
}Example form_submit payload
{
"event_type": "form_submit",
"form_id": "contact-form",
"form_completeness": 100
}Email capture
When a visitor types into an <input type="email"> field, warm.js captures the value at change time and includes it as captured_email in the form_update payload — provided:
- The field passes the sensitive-field deny-list check (see next section), and
- The value is longer than 3 characters and contains
@.
{
"event_type": "form_update",
"form_id": "demo-request",
"field_label": "Business email",
"field_type": "email",
"form_completeness": 40,
"captured_email": "sarah@goldmansachs.com"
}A captured email upgrades the visitor in the Warm AI pipeline from IP-anonymous to email-identified. This is the primary path for identifying EU visitors where IP-to-individual resolution is not available.
Sensitive field deny-list
warm.js never captures values from fields matching the following rules:
Always excluded by input type:
type="password"type="hidden"
Excluded by field name or ID (regex match):
/card|cvv|ccv|ssn|tax|security|pin/iThe regex is tested against the concatenation of el.name and el.id. Fields matching any of these patterns are silently skipped — no captured_email is emitted and no value is sent.
If you have sensitive fields not covered by the default deny-list, contact us at support@warmai.uk. Per-account field exclusions will be configurable in the dashboard in a future release.
Transport
Events are sent via navigator.sendBeacon when available. If sendBeacon is not supported (rare), fetch with keepalive: true is used as a fallback.
The primary endpoint is https://track.getwarmai.com/api/track — a Cloudflare Worker that adds the real client IP (cf-connecting-ip) and country code, then forwards to the Supabase edge function. If the proxy is unreachable, the script falls back directly to the Supabase endpoint.
Compliance
warm.js sets the warm_device first-party cookie, which is non-essential. UK and EU sites must display a consent banner and block the script until the visitor accepts. See Cookies & Consent for a full breakdown including Cookiebot, OneTrust, and Termly configurations.
Troubleshooting
If you don’t see sessions landing in the dashboard within a minute of a page view, work through this checklist:
- Script tag position — the tag should be in
<head>, not inside a component that mounts multiple times. - Tracking ID — confirm the
data-idvalue matches thetracking_idin your Warm AI account exactly. - Network tab — open DevTools → Network, filter for
warm.js. A 404 means the CDN URL is wrong. No request at all means the tag isn’t executing. - POST to track.getwarmai.com — filter Network for
api/track. If warm.js loaded but no POST fires, check CSPconnect-src(must allowtrack.getwarmai.com) or a consent manager blocking the script. - Consent manager — if you use Cookiebot, OneTrust, or Termly, the script will be blocked until consent is granted. Check the CMP dashboard to confirm warm.js is classified as
analytics/statisticsand not blocked permanently.
For platform-specific issues (WordPress, WP Rocket, Cloudflare Rocket Loader, Autoptimize), see the detailed install troubleshooting guide.