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 --fail-with-body --silent --show-error --max-time 20 \
'https://clicktag.io/api/sites/demo.bitgate.dev/overview?tz=UTC'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:
{
"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 theiris_uniqueentry flag instead. Event visitors (theeventsbreakdown 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_sis retained for API compatibility; it is not an arithmetic mean. Missing observations returnnull. - 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.
livecounts 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). seriesis zero-filled: every bucket from the one holdingfromthrough the one holding now, or the last second of the range if that comes first, is present, and quiet buckets have zero counts withduration_sandscrollednull. The first bucket can start beforefrom, and a range that starts in the future returns just the bucket holdingfrom. Whenforecastis not null, its bucket is inseries.
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 --fail-with-body --silent --show-error --max-time 20 \
'https://clicktag.io/api/sites/demo.bitgate.dev/overview/summary?limit=5'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:
{
"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 --fail-with-body --silent --show-error --max-time 20 \
'https://clicktag.io/api/sites/demo.bitgate.dev/breakdown?dim=pages&limit=10'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 --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.
totalsandprevious:visitors,newandreturningbrowsers in the range and in the period right before it, which is as many days long aswindow.fromthroughwindow.to(a range reaching past today is cut back to today first). Each browser counts once per range.series: one row per day withday(YYYY-MM-DD),newandreturning, from the first day in the range with a check-in throughwindow.to. Later days without check-ins are zero; a range without check-ins returns[].cohorts: one row per week of first visits, oldest first, withsize(new browsers that week) andvisitors, wherevisitors[0]equalssizeandvisitors[n]how many of them came back in weekn(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_shareandworst_share(highest and lowest single-cohort share),cohorts(how many) andsize(their summed size). A cohort counts for weeknonly once that week has ended.window: the resolved days:from,to(never after the current UTC date),previous_from,previous_to, andfirst_day, the first day with retention data for the site (nullbefore 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 --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'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.