# ThunderStats — Full Documentation > Privacy-first web analytics on Cloudflare Workers. Every documentation page, > inlined as markdown. Source: https://thunderstats.com/docs/ > Last updated: 2026-08-18 --- # Overview ThunderStats is privacy-first web analytics: a ~2 KB tracking script, no cookies, no consent banner required. You can track from the browser, from your server, or both. ## Quickstart Create a site in the dashboard (https://dash.thunderstats.com/register), then add this to your ``: ```html ``` That is the whole install. Pageviews appear within a few seconds, including single-page-app route changes. ## How the tracking works ThunderStats identifies a visit without storing anything on the device and without keeping the IP address. - **No cookies and no local storage.** Nothing is written to the browser, so no consent banner is required for the analytics itself. - **The IP address is never stored.** It is used in-request to look up an approximate country and city, and as one input to a session hash, then discarded. - **Sessions are a salted daily hash.** A visitor is identified by an HMAC-SHA256 of their IP, user agent, site ID and the current UTC date, truncated to 16 hex characters. It rotates every day, cannot be reversed, and differs per site. --- # Install the tracker One script tag in the ``. About 2 KB gzipped, loads deferred, never blocks rendering. ## The script tag Find your site ID in Settings, then add: ```html ``` Pageviews are sent on load and on every `pushState` / `replaceState` / `popstate`, so single-page apps work without extra configuration. ## Optional auto-tracking Add these attributes to the same script tag: | Attribute | What it does | | --- | --- | | `data-track-clicks` | Fires `Outbound Click` for links to other domains and `File Download` for common file extensions (pdf, zip, dmg, docx, mp4 and similar). | | `data-track-scroll` | Fires `Scroll Depth` once per page at 25%, 50%, 75%, 90% and 100%. | | `data-track-404` | Fires a `404` event when the page title or first heading looks like a not-found page. Powers the Pages → Not found report. | ```html ``` ## Content Security Policy If your site sends a CSP header, allow the tracker host: ``` script-src https://t.thunderstats.com connect-src https://t.thunderstats.com ``` If no external script is allowed at all, the dashboard can generate an inline build of the tracker that needs only `connect-src`. In Settings, switch the snippet to "Inline (CSP-safe)". ## WordPress Install the ThunderStats plugin and connect it from Settings → ThunderStats. Two modes are available: - **JavaScript** — injects the script tag, with checkboxes for the auto-tracking options above. - **Server-side** — sends pageviews from PHP with no JavaScript at all. Immune to ad blockers, nothing added to the page. Requires an API key. ## Verifying the install Open your site in a normal browser tab and check Realtime in the dashboard. Your visit should appear within a few seconds. If nothing shows up, the usual causes are an ad blocker on your own machine, a CSP blocking the request, or a site ID mismatch. Server-side tracking avoids the first two entirely. --- # Custom events Anything beyond a pageview: signups, purchases, downloads, form submissions. Send them from the browser, from your backend, or both. No setup is required — send an event with a new name and it appears in the dashboard the first time it fires. Event names are never pre-registered. ## From the browser ```js thunderstats.track('signup') // with properties thunderstats.track('signup', { plan: 'pro', source: 'landing' }) // with properties and revenue thunderstats.track('purchase', { plan: 'pro' }, 29.00) ``` Wire it to anything: ```html ``` The call is a no-op if the script has not loaded (blocked, still loading, offline), so analytics can never break your page. ## From your server Useful for things the browser cannot confirm: a payment that actually cleared, a subscription renewal, a signup that passed verification. ```bash curl -X POST https://api.thunderstats.com/api/collect/event \ -H "Authorization: Bearer ts_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "purchase", "url": "https://yoursite.com/checkout/thanks", "props": { "plan": "pro" }, "revenue": 29.00, "ip": "203.0.113.10", "ua": "Mozilla/5.0 ..." }' ``` Browser events and server events with the same name are the same event in the dashboard. **Important:** pass the visitor's `ip` and `ua`, not your server's. They are what tie a server-sent event to the visit. Send the same values the visitor's pageview carried and the event joins that session, so it counts in funnels and appears on the visitor timeline. Omit them and the event still counts but lands in a session of its own. Neither value is stored. ## Limits and rules | Field | Rule | | --- | --- | | `name` | Up to 128 characters, any string. Names beginning with `_` are reserved for internal use and hidden from the events list. | | `props` | Up to 20 keys. Values must be a string or a number; string values up to 256 characters. | | `revenue` | Any finite number, stored as-is. Use one currency consistently — ThunderStats does not convert. | ## Reading events back The Events page lists every event name with count, unique visitors and total revenue. Expanding a row gives: - **Recent events** — individual occurrences with their properties, page, and a link to the visitor timeline that fired them. - **Property breakdown** — counts grouped by the value of one property, with the keys that event actually carries offered as a pick list. --- # Goals and funnels A goal turns a page or an event into a conversion rate. A funnel shows where people fall out on the way there. ## Goals Two kinds: | Type | Matches on | Example | | --- | --- | --- | | Page | The URL path. A trailing `*` matches any suffix. | `/signup/success`, `/checkout/*` | | Event | A custom event name, matched exactly. | `purchase`, `cta_click` | ThunderStats counts unique visitors who reached the goal and shows a conversion rate against total visitors for the period. Opening a goal gives a per-goal breakdown — sources, landing pages, countries, devices — limited to sessions that converted. Goals match on event name regardless of origin, so an event sent from your backend counts toward an existing goal with no extra configuration. ## Funnels An ordered list of steps, each either a page or an event; they can be mixed. - Page steps match the URL path exactly, or use a trailing `*` for a prefix match. - Event steps match a custom event name. ``` 1. page /pricing 2. page /checkout/* 3. event purchase ``` Each step reports the visitors who reached it and the drop-off from the step before. Funnels can be run ad-hoc or saved. Steps are matched in order within a single visitor session, so a funnel measures one visit rather than a journey across days. --- # HTTP API Base URL: `https://api.thunderstats.com` ## Authentication Create an API key under Settings → API Keys. Keys are shown once at creation. ``` Authorization: Bearer ts_your_api_key ``` - A key is scoped to a single site and can never read or write another site, even one you own. - A key is read-only except for the collect endpoints below. It cannot change settings, manage goals, or touch your account. ## POST /api/collect/event — send a custom event Records a conversion or any other named action. Returns `202 {"ok": true}`. | Field | Type | Notes | | --- | --- | --- | | `name` | string | **Required.** Up to 128 characters. Matches goals and funnel steps by this name. | | `url` | string | **Required.** Must be an http(s) URL on your site's domain or a subdomain, else `403`. Query string is stripped before storage. | | `props` | object | Up to 20 keys; values string or number, strings up to 256 characters. | | `revenue` | number | Any finite number. Negative values allowed for refunds. | | `ip` | string | The visitor's IP. Used to resolve country/city and derive the session, then discarded — never stored. | | `ua` | string | The visitor's User-Agent, used for browser, OS and device. | | `country` / `city` | string | Set these to skip the IP lookup. `country` is ISO 3166 alpha-2, uppercase (e.g. `US`). | | `session_id` | string | Your own session identifier, 8–32 chars of `[A-Za-z0-9_-]`. Used instead of IP + user agent grouping. | | `timestamp` | number | Unix seconds. Defaults to now. Must be within the last 2 years and no more than 5 minutes in the future. | ```bash curl -X POST https://api.thunderstats.com/api/collect/event \ -H "Authorization: Bearer ts_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "purchase", "url": "https://yoursite.com/checkout/thanks", "props": { "plan": "pro", "seats": 3 }, "revenue": 29.00, "ip": "203.0.113.10", "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1" }' ``` ## How events join a visit Without `session_id`, ThunderStats derives one by hashing IP + user agent + site ID + current UTC date. Two requests with the same IP and user agent on the same day land in the same session. Send the *visitor's* IP and user agent, not your server's. If every event carries your server's details, every visitor collapses into one session. If you send nothing, each event becomes its own session — it still counts, but is not attributed to the visit that caused it. ## POST /api/collect — send a pageview Same authentication, domain check and session rules. Returns `202`. | Field | Type | Notes | | --- | --- | --- | | `url` | string | **Required.** Full URL of the page. UTM parameters are parsed from it and stored separately. | | `referrer` | string | The referring URL. Reduced to origin + path. | | `ip`, `ua`, `country`, `city`, `session_id`, `timestamp` | — | Identical to the custom event endpoint. | | `language` | string | Visitor language, up to 64 characters. | ## POST /api/collect/batch — send pageviews in bulk Takes `{"events": [ ... ]}` with up to 100 pageview objects, each shaped like the single endpoint. Returns `202`: ```json { "ok": true, "accepted": 97, "rejected_url": 2, "ignored_bot": 1, "ignored_quota": 0 } ``` Invalid events are skipped individually rather than failing the batch. Events whose `ua` is a known crawler, HTTP client or monitoring tool are acknowledged but not stored, on every collect endpoint — the same filter the JavaScript snippet applies. The single-event endpoints answer `{"ok": true, "ignored": "bot"}` for those. Every site has a daily event quota set by its plan (UTC day). Once it is reached, further events that day are acknowledged with `202` but not stored: `"ignored": "quota"` on the single-event endpoints, `ignored_quota` in the batch response. Do not retry them. ## Reading your data The same API key reads any per-site endpoint with GET. Every read takes `?site_id=`, which must match the key's site. **Quota.** Every request made with an API key counts toward the site's daily API quota (UTC day), except the collect endpoints, which count toward the event quota instead. The default depends on the plan; higher limits are available on request. Each response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds). Once the quota is used up the API answers `429` with a `Retry-After` header until the next reset. Today's usage, per key, is shown under Settings → API Keys. | Endpoint | Returns | | --- | --- | | `/api/stats/overview` | Pageviews, visitors, bounce rate, pages per visit, with period-over-period change. | | `/api/stats/all` | Every overview widget in one request. | | `/api/stats/pages` | Top pages. | | `/api/stats/referrers` | Top referring domains. | | `/api/stats/channels` | Traffic grouped into Direct, Organic Search, Social, Paid, Email, Referral. | | `/api/stats/countries`, `/cities`, `/devices`, `/browsers`, `/os` | Audience breakdowns. | | `/api/stats/trend` | A time series for the selected period. | | `/api/stats/realtime` | Active visitors and what they are looking at now. | | `/api/stats/sessions` | Individual visitor sessions. | | `/api/events` | Custom event names with counts, visitors and revenue. | | `/api/events/recent` | Individual occurrences of one event, with properties and session IDs. Takes `event_name` and optional `limit` (max 100). | | `/api/events/summary` | The top events for the period with their most common property values, in one request. Optional `limit` (max 10 events); up to 3 property keys per event and 4 values per key. | | `/api/events/log` | Every custom event in the period, newest first, with `total`. Optional `event_name`, free-text `q` (matches name, URL and properties), the segment filters (`country`, `device_type`, `utm_source`, …), `limit` (max 100) and `offset` (max 10,000). | | `/api/events/prop-keys` | Which property keys an event carries. Takes `event_name`. | | `/api/events/props` | Counts grouped by the value of one property. Takes `event_name` and `prop_key`. | | `/api/goals/stats` | Every goal with conversions and conversion rate. | | `/api/funnels` | Saved funnels and their results. | ### Selecting a period Pass `period` as one of `today`, `7d`, `30d`, `90d`, `12mo`, `custom`. With `custom`, also pass `date_from` and `date_to` as `YYYY-MM-DD`. An unrecognised value falls back to `30d`. ### Filtering Most read endpoints accept these filters, combined with AND: `country`, `city`, `browser`, `os`, `device_type`, `referrer_domain`, `path`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` Comma-separate values for OR, prefix with `!` to negate: ``` ?site_id=abc&period=7d&country=US,CA ?site_id=abc&period=30d&referrer_domain=!spam.example ?site_id=abc&period=7d&city=Berlin&device_type=mobile ``` ## Rate limits The collect endpoints allow a burst of 120 events and a sustained 20 events per second, per site. Pageviews and custom events share one budget. Over the limit you get `429`; retry after a moment. ## Errors | Status | Meaning | | --- | --- | | `202` | Accepted. The event is queued for storage. | | `400` | Payload failed validation. The `error` field says which field and why. | | `401` | Missing, malformed, revoked or expired API key. | | `403` | The `url` hostname does not belong to the key's site, or the key is not allowed on that endpoint. | | `404` | Unknown `site_id`, or a site the key cannot reach. | | `429` | Rate limited. | Errors return JSON: `{"error": "timestamp must be within the last 2 years..."}`