# 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](/openapi.json) | [Authentication](/docs/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](/docs/authentication#management-keys) 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](#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](#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](#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](#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](/docs/sites#api-only-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](/docs/sites#errors-and-caching) can delay visibility changes.
