Clicktag docs

Stats API

The Clicktag Stats API returns JSON pageviews, visitors, live visitors and breakdowns by page, referrer, country, device, browser, UTM campaign or event, for any range up to 400 days. Public sites need no token; private ones take the owner's Firebase ID token or a management key with read scope.

Base URL: https://clicktag.io. OpenAPI document | Authentication

TypeScript examples run server-side in Node 20+: save a snippet as example.mts, set any referenced environment variables, then run npx tsx example.mts.

Read a public report

GET /api/sites/{hostname}/overview

These examples only read demo.bitgate.dev. The curl example uses the default trailing 30 days; the TypeScript example selects the last 24 complete UTC hours.

curl
curl --fail-with-body --silent --show-error --max-time 20 \
  'https://clicktag.io/api/sites/demo.bitgate.dev/overview?tz=UTC'
typescript
const to = Math.floor(Date.now() / 3_600_000) * 3_600;
const query = new URLSearchParams({
  from: String(to - 86_400),
  to: String(to),
  tz: "UTC",
});
const response = await fetch(
  `https://clicktag.io/api/sites/demo.bitgate.dev/overview?${query}`,
  { signal: AbortSignal.timeout(20_000) },
);
if (!response.ok) {
  throw new Error(`Overview ${response.status}: ${await response.text()}`);
}
console.log(await response.json());

Private reads add Authorization: Bearer <Firebase ID token>, or a management key with the read scope in its place. Product API responses have no cross-origin CORS headers: use a server-side client or the Clicktag origin, even for public reports.

Date ranges

The overview and breakdown endpoints share the same range parser.

Parameter Type Default and bounds
from number Unix seconds, inclusive; rounded down. Defaults to to - 30 * 86400.
to number Unix seconds, exclusive; rounded up. Defaults to current Unix seconds.
tz string Defaults to UTC. Sets overview buckets, the forecast bucket and summary day labels; retention uses it to select calendar dates. Ignored by breakdown.

The rounded range must satisfy from < to and span no more than 400 days. Send seconds, not JavaScript milliseconds. Omitted bounds use their defaults. Empty, non-numeric, non-finite, reversed, negative or out-of-range values return 400 invalid range; zero is a valid explicit Unix timestamp when the resulting range is valid.

Timezone handling

Use UTC or a recognized IANA zone such as Europe/Amsterdam or America/Argentina/Buenos_Aires. Invalid or unknown zones fall back to UTC. Daily series timestamps represent the start of the calendar day in the requested timezone, including daylight-saving changes; account summaries return explicit YYYY-MM-DD keys.

Dashboard dates use your browser timezone. A custom range through today advances on refresh until the selected date ends; historical custom dates remain fixed. Presets such as Today continue rolling with the calendar. Next to the dates, the dashboard names the window its changes compare with, for example vs yesterday, same time.

Hour boundaries

Overview, breakdowns and site summaries use exact timestamp boundaries, including partial hours and fractional-offset timezones. Repeated nonempty row IDs are counted once per site and row type; rows without IDs cannot be deduplicated this way.

Engagement belongs to the original pageview's timestamp and bot status, not the append receipt time. The previous period applies the same rules to the window in previous_range.

Overview response

Field Type Meaning
totals object Metrics for the requested range
previous object Same metrics for previous_range
totals.pageviews, previous.pageviews number Canonical non-bot pageview count
totals.visitors, previous.visitors number Visitors: distinct daily visitor hashes on non-bot pageviews
totals.avg_duration_s, previous.avg_duration_s number or null Median per-page summed duration, eligible at five seconds; null if none
totals.avg_scroll, previous.avg_scroll number or null Mean of measured per-page maximum scroll percentages; null if none
series array Time-ordered buckets, zero-filled from the bucket holding from through the one holding now or the end of the range
series[].t number Bucket timestamp in Unix seconds
series[].pageviews number Non-bot pageviews in the bucket
series[].visitors number Visitors in the bucket; someone active in several buckets counts in each
series[].duration_s number or null Median seconds on page for pageviews with at least 5 s in that bucket, or null
series[].scrolled number or null Mean scroll depth 0-100 in that bucket, or null
forecast object or null null unless the range reaches into the current day or hour
forecast.bucket number Start of the current bucket in Unix seconds, matching series[].t
forecast.pageviews, forecast.visitors number Expected end-of-bucket totals, never below the actual values so far
forecast.basis profile or elapsed profile: the site's own hour-of-day shape over the same weekday in the last 4 weeks, falling back to the last 14 days. elapsed: share of the bucket's time elapsed, always for hourly buckets
forecast.elapsed number Elapsed fraction of the bucket at the earlier of to and now, 0 to 1
live number Distinct nonempty live visitor identifiers in the last five minutes
granularity hour or day hour for ranges up to four days; day for longer ranges
previous_range object from and to in Unix seconds: the same clock times as the range, moved back by as many calendar days as it covers in tz. Today until 12:35 compares with yesterday until 12:35, the last 7 days with the 7 days before

Example response:

json
{
  "totals": {
    "pageviews": 3,
    "visitors": 2,
    "avg_duration_s": 42,
    "avg_scroll": 75
  },
  "previous": {
    "pageviews": 2,
    "visitors": 2,
    "avg_duration_s": 30,
    "avg_scroll": 50
  },
  "series": [
    { "t": 1789516800, "pageviews": 1, "visitors": 1, "duration_s": 36, "scrolled": 80 },
    { "t": 1789520400, "pageviews": 2, "visitors": 1, "duration_s": 48, "scrolled": 70 }
  ],
  "forecast": {
    "bucket": 1789520400,
    "pageviews": 4,
    "visitors": 2,
    "basis": "elapsed",
    "elapsed": 0.5
  },
  "live": 0,
  "granularity": "hour",
  "previous_range": { "from": 1789430400, "to": 1789435800 }
}

What the counts mean

  • Visitors (API field visitors) count distinct daily visitor hashes on non-bot pageviews. The hash changes every UTC day, so someone who comes back on another day counts again, and someone active in two time buckets counts in both. Pageviews without a hash (mainly Simple Analytics imports and older data) count their is_unique entry flag instead. Event visitors (the events breakdown and the overview summary's event numbers) count nonempty hashes only, so events without a hash add nothing.
  • Time on page sums legitimate duration increments per canonical human pageview, then takes the median of totals at least five seconds. avg_duration_s is retained for API compatibility; it is not an arithmetic mean. Missing observations return null.
  • Avg. scroll takes each page’s maximum measured scroll and averages those page values. Explicit zero contributes; missing scroll does not.
  • Append updates use the parent pageview’s date and bot status. Unlinked updates and updates with bot parents do not contribute to engagement or live activity.
  • live counts distinct nonempty visitor hashes from human pageviews/events in the last five minutes, plus recent append activity linked to human parents. It is independent of the selected range; imported blank identities do not contribute.
  • Country is inferred from browser IANA timezone, with unknown/non-geographic zones left blank. Imports retain SA’s timezone-based country; exact proprietary mapping can differ. Browser and OS names share normalization across collection and imports (for example iOS Safari → Mobile Safari, Mac OS → macOS).
  • series is zero-filled: every bucket from the one holding from through the one holding now, or the last second of the range if that comes first, is present, and quiet buckets have zero counts with duration_s and scrolled null. The first bucket can start before from, and a range that starts in the future returns just the bucket holding from. When forecast is not null, its bucket is in series.

Overview summary

GET /api/sites/{hostname}/overview/summary

One request returns what the dashboard Overview lists under its chart: the top devices, browsers and operating systems, custom event totals with the previous period and the top event names, and the pages people have open right now. Access, ranges and filters work as on the overview, and anonymous reads of public sites are edge-cached for 60 seconds.

curl
curl --fail-with-body --silent --show-error --max-time 20 \
  'https://clicktag.io/api/sites/demo.bitgate.dev/overview/summary?limit=5'
typescript
const query = new URLSearchParams({ limit: "5" });
const response = await fetch(
  `https://clicktag.io/api/sites/demo.bitgate.dev/overview/summary?${query}`,
  { signal: AbortSignal.timeout(20_000) },
);
if (!response.ok) {
  throw new Error(`Overview summary ${response.status}: ${await response.text()}`);
}
console.log(await response.json());
Parameter Type Default and bounds
from, to number Same range rules as overview; defaults to the trailing 30 days
limit integer Rows per list. Default 5; numeric values clamp to 1-20 and fractions round down. Zero or non-numeric input uses 5.
f string Repeated <key>:<value> filters with the same keys and limits as overview

tz sets the calendar days behind events.previous; nothing in the response is bucketed by time. live.pages ignores from and to; it always covers the last 30 minutes. events.totals.visitors counts distinct visitor hashes behind the events.

Field Type Meaning
events.totals.count number Canonical non-bot custom events with a name, in [from, to)
events.totals.visitors number Distinct nonempty visitor identifiers behind those events
events.previous object Same two numbers for the overview's previous_range
events.top[] array Top limit event names by count: name, value (events) and visitors as above
tech.devices[], tech.browsers[], tech.os[] array Top limit per dimension: name, value (pageviews) and visitors; names match the breakdowns and blanks are left out
live.pages[] array Top limit paths by distinct nonempty visitor identifiers on human pageviews in the last 30 minutes, independent of the range. The overview live count uses five minutes instead, see What the counts mean

Example response:

json
{
  "events": {
    "totals": { "count": 1204, "visitors": 310 },
    "previous": { "count": 980, "visitors": 251 },
    "top": [{ "name": "signup", "value": 42, "visitors": 40 }]
  },
  "tech": {
    "devices": [{ "name": "desktop", "value": 900, "visitors": 612 }],
    "browsers": [{ "name": "Chrome", "value": 640, "visitors": 410 }],
    "os": [{ "name": "macOS", "value": 380, "visitors": 250 }]
  },
  "live": {
    "pages": [{ "name": "/pricing", "visitors": 3 }]
  }
}
Status Message Meaning
400 invalid range Reversed, empty, non-finite or over-400-day rounded range
400 invalid filter Unknown key, empty or over-256-character value, or more than 12 filters
401 invalid or revoked management key The management key sent is unknown or revoked
403 scope read required The management key lacks the read scope
403 Install your tracking script to verify this site first Owner is requesting statistics for an unverified site
404 unknown site Hostname is absent, or the caller cannot access it
500 internal error Query failed

Breakdowns

GET /api/sites/{hostname}/breakdown

Parameter Type Default and bounds
dim string Required; one of the ten dimensions below
from, to number Same UTC range rules as overview
limit integer Default 10; numeric values clamp to 1-100 and fractions round down. Zero or non-numeric input uses 10.

tz has no effect on this endpoint. Rows are ordered by count descending, then by name. Filters work here too. There is no cursor, offset, total-row count or metadata filter.

Dimension Groups by value counts
pages Page path Pageviews
referrers Acquisition source Pageviews
countries Country Pageviews
devices Parsed device category Pageviews
browsers Parsed browser name Pageviews
os Operating system name Pageviews
utm_sources utm_source Pageviews
utm_mediums utm_medium Pageviews
utm_campaigns utm_campaign Pageviews
events Event name Event rows

All breakdowns exclude bot rows. Acquisition sources prefer stored utm_source, then query utm_source, source, or ref, then the referring host. Same-site navigation is excluded, including matching original hostnames and apex/www variants; it is not relabeled Direct. Google search country domains, the Android Google app and imported google referrers group under google.com; Gmail's Android app counts as mail.google.com and other Android apps by package name, while other Google services remain separate. Source value still counts pageviews, not only entries. Blank values are omitted except for pages (shown as /) and referrers (shown as Direct / none). Event metadata is stored by the collector but cannot be retrieved, grouped or filtered through this API. utm_term and utm_content are not breakdown dimensions.

curl
curl --fail-with-body --silent --show-error --max-time 20 \
  'https://clicktag.io/api/sites/demo.bitgate.dev/breakdown?dim=pages&limit=10'
typescript
const query = new URLSearchParams({ dim: "pages", limit: "10" });
const response = await fetch(
  `https://clicktag.io/api/sites/demo.bitgate.dev/breakdown?${query}`,
  { signal: AbortSignal.timeout(20_000) },
);
if (!response.ok) {
  throw new Error(`Breakdown ${response.status}: ${await response.text()}`);
}
console.log(await response.json());

Success is 200 with { "rows": [...] }. Each row has name, value (matching pageviews or events), and visitors. visitors counts distinct nonempty visitor hashes within each group. In pageview dimensions, pageviews without a hash (mainly imports and older data) add their entry flag, as in the overview; in events, events without a hash add nothing. events rows also have last_seen, the time of the newest matching event in the range as an ISO 8601 UTC string cut to whole seconds, for example 2026-10-07T08:51:21.000Z. An empty report returns { "rows": [] }. Do not add different dimensions together; omitted labels, source exclusions and limit can also make a breakdown smaller than the overview.

Filters

Overview and breakdown both accept repeated f=<key>:<value> parameters that narrow the report to breakdown rows. The value is a row's name exactly as the breakdown returns it, including / and Direct / none. Everything after the first colon is the value, so paths containing colons work. URL-encode each parameter.

Key Breakdown
page pages
referrer referrers
country countries
device devices
browser browsers
os os
utm_source utm_sources
utm_medium utm_mediums
utm_campaign utm_campaigns
event events

Repeating a key matches any of its values; different keys must all match. At most 12 filters, each value at most 256 characters. Pageview filters keep exactly the rows their breakdown row counts, so a filtered overview reports that row's pageviews and visitors. event keeps pageviews from sessions that fired the event; the other way round, pageview filters keep events from sessions with a matching pageview. Live counts are filtered the same way. Retention and the all-sites summary take no filters.

curl
curl --fail-with-body --silent --show-error --max-time 20 --get \
  'https://clicktag.io/api/sites/demo.bitgate.dev/breakdown' \
  --data-urlencode 'dim=pages' \
  --data-urlencode 'f=country:NL' \
  --data-urlencode 'f=referrer:Direct / none'

Retention

GET /api/sites/{hostname}/retention?from=<unix>&to=<unix>&tz=<iana>

Same access and range validation as the overview, but retention measures whole dates rather than partial hours: from and to - 1 are converted to calendar dates in tz, then used as inclusive bounds over UTC-day records. Counts come from the tracker's once-a-day return check, grouped by UTC day and by cohort week (starting Monday). It does not accept f filters.

  • totals and previous: visitors, new and returning browsers in the range and in the period right before it, which is as many days long as window.from through window.to (a range reaching past today is cut back to today first). Each browser counts once per range.
  • series: one row per day with day (YYYY-MM-DD), new and returning, from the first day in the range with a check-in through window.to. Later days without check-ins are zero; a range without check-ins returns [].
  • cohorts: one row per week of first visits, oldest first, with size (new browsers that week) and visitors, where visitors[0] equals size and visitors[n] how many of them came back in week n (up to 12). The last value of a row can belong to the current, unfinished week.
  • week: Monday of the current UTC week.
  • curve: one row per follow-up week that at least one cohort has completed: week (1 to 12), retained_share (returning browsers divided by the summed size of those cohorts), best_share and worst_share (highest and lowest single-cohort share), cohorts (how many) and size (their summed size). A cohort counts for week n only once that week has ended.
  • window: the resolved days: from, to (never after the current UTC date), previous_from, previous_to, and first_day, the first day with retention data for the site (null before the first check-in).

Other tabs, hard reloads and private windows on the same IP address and browser count once per UTC day. A browser counts as new again after its HTTP cache is cleared and in a private window on a later day. With data-retention="false" the script skips the check, so those browsers are not counted at all.

All-sites summary

GET /api/sites/summary?tz=UTC

Requires a Firebase ID token or a management key with the read scope, even when some owned sites are public. Returns that account's verified sites plus all of its API sites, verified or not, pinned sites first (most recently pinned on top), then by creation time, with a rolling window beginning 31 days ago and an independent five-minute live count. Unverified websites are left out. Only tz is read; from, to and limit do not customize this endpoint.

curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \
  'https://clicktag.io/api/sites/summary?tz=UTC'
typescript
const token = process.env.TT_TOKEN;
if (!token) throw new Error("Set TT_TOKEN from the Authentication page");

const response = await fetch(
  "https://clicktag.io/api/sites/summary?tz=UTC",
  {
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(20_000),
  },
);
if (!response.ok) {
  throw new Error(`Summary ${response.status}: ${await response.text()}`);
}
console.log(await response.json());
Field Type Meaning
sites array Verified sites plus API sites; empty when there are none
sites[].hostname string Registered hostname
sites[].pinned_at string or null ISO 8601 time the site was pinned, or null
sites[].live number Same five-minute live definition as overview
sites[].days array Available days, ascending; not zero-filled
sites[].days[].day string YYYY-MM-DD calendar date in tz
sites[].days[].pageviews number Canonical non-bot pageview count
sites[].days[].visitors number Visitors for the day, counted as in the overview
sites[].api_days array API sites only: days with API requests, ascending; not zero-filled
sites[].api_days[].day string YYYY-MM-DD calendar date in tz
sites[].api_days[].requests number API requests that day
sites[].api_days[].server_errors number Requests that day that returned a 5xx status

Sites without activity remain present with days: [] and live: 0. API sites also carry api_days, counted from the moment the site was registered. days and live only count website traffic, so an API site that is not verified always has days: [] and live: 0. The rolling cutoff can give a partial first day; this is not 31 complete calendar days. Day grouping uses each pageview’s actual timestamp in tz, including fractional offsets and daylight-saving changes.

Exporting data

There is no HTTP export endpoint. Fetch overview series or breakdown rows as JSON and convert them in your client. The dashboard's traffic CSV downloads the current overview series with Timestamp (UTC), Pageviews and Visitors columns; it does not export raw events.

Errors

Errors are JSON: { "error": "message" }. Freshly computed JSON responses and handled errors carry Cache-Control: no-store. Reads of a public site's overview, overview summary, breakdown and retention by anyone other than the owner are edge-cached for 60 seconds. Cache hits on clicktag.io and clicktag.app carry Cache-Control: public, max-age=60. Legacy Totallytics domains can still return max-age=14400, allowing a browser to reuse a cached response for up to four hours. Only the owner's requests bypass that response cache.

Status Message Meaning
400 invalid range Reversed, empty, non-finite or over-400-day rounded range
400 unknown dimension Missing or unsupported dim
400 invalid filter Unknown key, empty or over-256-character value, or more than 12 filters
401 sign in required Account summary needs a valid token
401 invalid or revoked management key The management key sent is unknown or revoked
403 scope read required The management key lacks the read scope
403 Install your tracking script to verify this site first Owner is requesting web statistics for an unverified site
404 unknown site Hostname is absent, or the caller cannot access it; private reads also use this status for missing or invalid tokens
500 internal error Query failed

Site access is checked before range or dimension validation. Public reports ignore a missing or invalid Firebase ID token because public access is sufficient. A management key is checked before the site lookup, so an unknown or revoked key gets 401 and a key without read gets 403, even on public reports. Site lookup caching can delay visibility changes.