# Collector reference

The Clicktag collector takes pageviews and events from `latest.js`, a 1x1 GIF beacon or one JSON `POST /events`, with no token or API key. [Register the hostname](/docs/sites#register-a-hostname) first: the collector ignores unregistered hostnames, even when it returns HTTP 200.

Base URL: `https://clicktag.io`. [OpenAPI document](/openapi.json) | [Install the tracker](/docs/installation)

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`.

## Routes

| Method    | Route           | Input                                              | Success         |
| --------- | --------------- | -------------------------------------------------- | --------------- |
| GET, HEAD | `/latest.js`    | Tracker configuration lives on the script tag      | JavaScript      |
| GET, HEAD | `/simple.gif`   | Query parameters                                   | 1x1 GIF         |
| GET, HEAD | `/noscript.gif` | Query parameters, with page details from `Referer` | 1x1 GIF         |
| GET       | `/r`            | `h` (hostname), plus the browser's cached validator | GIF, `304`, or `204` for missing hostname/bot user-agent; see [Return check](#return-check) |
| POST      | `/events`       | One JSON object                                    | Plain text `ok` |
| POST      | `/append`       | Same JSON object format as `/events`               | Plain text `ok` |
| GET, HEAD | `/healthz`      | None                                               | Plain text `ok` |

`/events` and `/append` are the same ingestion handler. Both default to `type: "pageview"`; the route name does not choose a row type. Set `type: "event"` for events and `type: "append"` for duration or scroll updates. There is no batch-array API. A JSON body can be at most 64 KiB; a larger one returns `413`.

`HEAD` on `/simple.gif` or `/noscript.gif` returns the pixel's headers and records nothing.

## Send JSON

The examples use `example.com`. Replace it with your registered hostname; never use the public demo as a write target.

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -X POST https://clicktag.io/events \
  -H 'Content-Type: application/json' \
  --data '{"hostname":"example.com","type":"event","event":"signup","metadata":{"plan":"pro"}}'
```

```typescript
const hostname = process.env.EA_HOSTNAME;
if (!hostname) throw new Error("Set EA_HOSTNAME to your registered hostname");

const response = await fetch("https://clicktag.io/events", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    hostname,
    type: "event",
    event: "signup",
    metadata: { plan: "pro" },
  }),
  signal: AbortSignal.timeout(20_000),
});
if (!response.ok) {
  throw new Error(`Collector ${response.status}: ${await response.text()}`);
}
console.log(await response.text());
```

A successful JSON request returns `200` with the literal body `ok`, not JSON. Send JSON text: `application/json` is recommended, and JSON sent as `text/plain` by `sendBeacon` is also accepted; the handler does not enforce Content-Type. Curl's default user agent is classified as a bot, so a curl test can succeed without appearing in reports. Server-side visitor attribution uses the sending request's IP and effective user agent, not an end user's identity supplied in JSON.

### Append duration and scroll

Appends create additional rows; they do not update an existing pageview. Use the original pageview's `id` as `original_id` (the tracker uses that same value for `page_id`). The collector does not require that ID or check that a matching pageview exists.

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -X POST https://clicktag.io/append \
  -H 'Content-Type: application/json' \
  --data '{"hostname":"example.com","type":"append","original_id":"pageview-id","duration":42,"scrolled":75}'
```

Duration is an incremental number of seconds, not a running total; scroll is a percentage. Give each independent append a new `id` and link `original_id` to its pageview. Reports sum duration increments and take maximum scroll per page, using the parent's date and bot status. Unlinked or bot-parent updates are excluded. Time on page is the median of page totals at least five seconds. Missing measurements stay absent, not zero. See [metric definitions](/docs/stats#what-the-counts-mean).

## Payload fields

The same fields work as query parameters on the pixels and as properties of the single JSON body on either POST route. Query values are strings. In JSON, scalar strings, numbers and booleans are converted to strings before most field processing; use the types below for predictable results. Unrecognized fields are ignored.

Text limits below are truncation limits, not validation errors. Missing optional text defaults to an empty string unless noted. Numeric measurements are rounded and capped. For `duration` and `scrolled`, missing, empty, malformed, non-finite or negative values are `null`; explicit zero is preserved. Viewport and screen dimensions retain zero as their missing-value default. In JSON, `null`, objects and arrays count as missing for every field except `metadata` and `brands`.

### Required and routing fields

| Field      | Type                    | Processing                                                                                                                                                                                                                                     |
| ---------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hostname` | string                  | Required. Normalized the way [registration](/docs/sites#register-a-hostname) does: trimmed and lowercased, with an `http://` or `https://` scheme, path and trailing dot dropped and international names in their ASCII `xn--` form. A value registration would reject, such as one with a port or an IP address, can't match a site, so the hit is accepted and nothing is stored. The result must equal a registered hostname; `www.example.com` is a different hostname from `example.com`. |
| `type`     | string                  | `pageview`, `event`, `append` or `error`; default `pageview` when missing or empty. Case-sensitive; truncated to 16 characters before validation.                                                                                              |
| `event`    | string                  | Required for `type: "event"`; otherwise ignored. Truncated to 256 characters, then non-ASCII-letter/digit runs become `_` and edge underscores are removed. Case is preserved; `Buy now!` becomes `Buy_now`. Empty after cleaning is an error. |
| `path`     | string                  | 2,048 characters. Defaults to `/` for a pageview and empty for other types.                                                                                                                                                                    |
| `query`    | string                  | Page query string without the leading `?`, up to 2,048 characters. Parsed for UTM parameters.                                                                                                                                                  |
| `referrer` | string                  | 2,048 characters. Accepts a full URL or `hostname/path`; parsed hostname is lowercased, without port, query or fragment. An `android-app://` referrer stores the app's package, or `google.com` for the Google app and `mail.google.com` for Gmail.                                                                                                                   |
| `metadata` | object, array or string | Objects and arrays are JSON-serialized; strings are kept as supplied; numbers, booleans and `null` store nothing. Stored text is truncated to 4,096 characters and can therefore cease to be valid JSON. Not exposed by the stats API; the [live view](/docs/live) shows the first 512 characters. |

### IDs and measurements

| Field                               | Type   | Processing                                                                       |
| ----------------------------------- | ------ | -------------------------------------------------------------------------------- |
| `id`                                | string | Row ID, up to 64 characters                                                      |
| `page_id`                           | string | Page correlation ID, up to 64 characters                                         |
| `session_id`                        | string | Session correlation ID, up to 64 characters; not used for visitor counting. [Filters](/docs/stats#filters) match pageviews and events to each other through it, so rows without one never match a filter on the other type |
| `original_id`                       | string | Original pageview correlation ID, up to 64 characters                            |
| `duration`                          | number | Seconds; integer output from `0` to `86400`                                      |
| `scrolled`                          | number | Percentage; integer output from `0` to `100`                                     |
| `viewport_width`, `viewport_height` | number | Viewport dimensions; integer output from `0` to `65535`                          |
| `screen_width`, `screen_height`     | number | Screen dimensions; integer output from `0` to `65535`                            |
| `error`                             | string | Error text, up to 2,048 characters; error rows have no dedicated stats breakdown |

Use one stable nonempty `id` per logical row. The collector has bounded, per-isolate replay suppression keyed by site, type and ID (up to 24 hours / 10,000 entries); reports also keep only the earliest row per site, type and ID. Rows without an `id` are never deduplicated. This is not a durable exactly-once delivery guarantee. Give legitimate pageviews, events and append increments different IDs; a fallback for the same append keeps its ID. `page_id`, `session_id`, and `original_id` are correlation fields, not interchangeable replay keys.

### Client context

| Field                   | Type   | Processing                                                                                                                                                          |
| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ua`                    | string | User agent, truncated to 512 characters. Falls back to the request's `User-Agent` header, truncated the same way. Drives browser, OS, device, visitor and bot classification. |
| `timezone`              | string | Browser IANA timezone, up to 64 characters; determines country, not row timestamps                                                                                             |
| `language`              | string | Language label, up to 16 characters                                                                                                                                 |
| `os_name`, `os_version` | string | Up to 64 characters each; non-empty supplied values override parsed OS values                                                                                       |
| `brands`                | string or array | Browser client-hint brands; stored serialized up to 1024 characters                                                                                                      |
| `version`               | string | Script version label, up to 32 characters                                                                                                                           |
| `hostname_original`     | string | Original hostname before an override, lowercased and truncated to 253 characters                                                                                    |

### Flags

Flags are true for boolean `true`, number `1`, string `"true"` or string `"1"`; other values are false. Defaults are false except `https`, which defaults to true when absent; in JSON, `null`, objects and arrays also count as absent.

| Field           | Meaning                                                                                          |
| --------------- | ------------------------------------------------------------------------------------------------ |
| `unique`        | SA-style entry flag, stored as `is_unique`; shows as an Entry label in the live view. Visitor counts use it only for rows without a visitor hash, such as imports. |
| `mobile`        | Mobile hint; parsed device type also contributes to the stored mobile flag. Sets device type `mobile` when the user agent reveals none |
| `bot`           | Marks a bot. False does not override bot detection from the user agent.                          |
| `brave`, `duck` | `brave` sets the browser to `Brave`; otherwise `duck` sets `DuckDuckGo`. The browser script sends them when it detects those browsers |
| `https`         | Whether the tracked page used HTTPS                                                              |
| `collect-dnt`   | Separate override, not a normal flag: only `true` or `"true"` bypasses a DNT skip; `1` does not. The browser script never sends it. |

### Campaign fields

| Field          | Type   | Processing                                                  |
| -------------- | ------ | ----------------------------------------------------------- |
| `utm_source`   | string | Up to 256 characters; available as `utm_sources` in stats   |
| `utm_medium`   | string | Up to 256 characters; available as `utm_mediums` in stats   |
| `utm_campaign` | string | Up to 256 characters; available as `utm_campaigns` in stats |
| `utm_term`     | string | Up to 256 characters; no stats breakdown                    |
| `utm_content`  | string | Up to 256 characters; no stats breakdown                    |

Source precedence is `utm_source`, `source`, then `ref`. Other campaign fields prefer `utm_<name>` over `<name>`. Within each name, a nonempty `query` value precedes the explicit top-level value, so the aliases `source`, `ref`, `medium`, `campaign`, `term` and `content` work in both places; they fill the matching `utm_*` field and have no column of their own. The resulting source feeds acquisition reports; same-site referrers are excluded there, not relabeled Direct. `data-strict-utm="true"` prevents the browser tracker from forwarding short aliases automatically.

### Server-derived fields

Row timestamps are assigned during ingestion. Country comes from the browser's IANA timezone using geographic timezone data; UTC, fixed offsets, absent and unknown zones remain unknown, with no IP fallback. Imported rows keep the country from the Simple Analytics export. Each collected row gets a visitor hash of the UTC day, a random salt for that day, the site, the request IP and the effective user agent; visitor counts use distinct hashes (see [metric definitions](/docs/stats#what-the-counts-mean)). If the day's salt is unavailable, the row is stored without a hash. Browser/OS labels are normalized consistently for live and imported rows (`iOS Safari` becomes `Mobile Safari`, `Mac OS` becomes `macOS`), with the `brave` and `duck` flags and supported client-hint brands used when supplied. Payload fields such as `timestamp`, `ts`, `ip`, `country` and `visitor_id` do not override them. The tracker's `time` cache-buster and `sri` field are also ignored by the collector.

## Pixels and script

### Pageview pixel

`GET /simple.gif` reads the payload fields from its query string. A valid request returns a 1x1 GIF, including for event, append or error types. An invalid payload returns a text error instead of an image.

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  --get https://clicktag.io/simple.gif \
  --data-urlencode 'hostname=example.com' \
  --data-urlencode 'type=pageview' \
  --data-urlencode 'path=/pricing' \
  --output /dev/null
```

For metadata on a pixel, send a URL-encoded JSON string rather than an object. A GET pixel is a collection request, not a read-only stats request.

### No-JavaScript pixel

`GET /noscript.gif` has the same payload format and GIF response. When absent from the query, `hostname`, `path`, `https` and `query` are derived from the page URL in the `Referer` header. Explicit parameters take precedence, even when empty. `type` defaults to `pageview`.

The incoming `Referer` is the page being tracked, not that page's acquisition referrer. The collector does not infer the acquisition `referrer` field from it. Browser referrer policies may omit the header or reduce it to an origin; then the hostname must be supplied explicitly or the path may become `/`.

### Browser script

`GET /latest.js` serves JavaScript with `Cache-Control: public, max-age=300, stale-while-revalidate=3600`. Install it with the configuration described in [Installation](/docs/installation) and use [Events](/docs/events) for browser event calls. Fetching the script itself does not record a pageview.

### Return check

`GET /r?h=<hostname>` records returning visitors for [retention](/docs/stats#retention). The browser script requests it with the first pageview of each page load, without credentials, unless `data-retention="false"` is set or the script flags the visitor as a bot. The response is cached privately until the next UTC midnight, so the browser can answer later page loads that day from its cache. After midnight the browser revalidates with `If-Modified-Since`; the collector reads the first and last visit days from it, stores one row for the new day and returns a new `Last-Modified`. A value from the current UTC day returns `304`; a missing or unreadable value counts as a first visit.

If the same visitor hash already checked in for that hostname during the UTC day, for example from another tab or a private window, the collector returns the stored `Last-Modified` and stores no row. `h` is normalized like the `hostname` field; a missing or invalid `h` or a bot `User-Agent` returns `204`. `/r` does not check Do Not Track headers or the `bot` flag. The registration gate applies as for other hits, so an unregistered hostname still gets a GIF.

## Receipt and filtering

A collector `200` acknowledges the handler's response, not a durable insert. Storage runs in the background; a later storage failure is not returned to the caller. Unregistered or unverified hostnames are discarded unless automatic ownership verification succeeds. Registration lookup failures fail closed rather than bypassing the gate. [Site caching](/docs/sites#errors-and-caching) can delay registration changes.

A hit for a registered but unverified hostname that has a `verify_token` starts an automatic check: the collector fetches `https://<hostname>/`, following redirects only to the same hostname with or without `www.`, and looks for a tracking script tag with that token in `data-verify` and this hostname in `data-hostname` (or as the page's hostname) ([Verify ownership](/docs/sites#verify-ownership)). The hit is stored only if the check passes. For 30 seconds after a check starts, further hits for that hostname at the same Cloudflare location are discarded without a new check, unless the check passed.

The normal success response is also returned when nothing is stored:

- `DNT: 1` or `X-Do-Not-Track: 1`, unless `collect-dnt` is `true`
- an unregistered hostname, including a `www.` or port variant of a registered one
- an unverified hostname that does not pass the automatic check
- a failed registration lookup or storage write
- a nonempty `id` that the same worker isolate already accepted for that site and type within 24 hours

There is no end-to-end delivery guarantee. Do not blindly retry after an ambiguous network failure or assign a fresh ID to the same logical row: the original may already have been processed. The tracker's image fallback after a rejected `sendBeacon` retains the append's ID and payload. Inspect the response and [reports](/docs/stats) separately when validating an integration.

If `DNT: 1` or `X-Do-Not-Track: 1` is present, collection is skipped and the normal success response is returned, unless `collect-dnt` is `true`. This check occurs before payload-field validation; POST bodies must still parse as JSON. The browser script also checks browser Do Not Track before sending; its `data-collect-dnt="true"` setting skips only that browser check and does not send `collect-dnt`, so the collector still drops hits that carry a `DNT: 1` header.

Bot rows are stored with `is_bot` set and are excluded from reports and the live view. Detection uses the `bot` flag, which the browser script sets from automation and crawler hints, known automated user-agent signatures in the effective user agent, and hits carrying the script's `version` field from major cloud networks (AWS, Google Cloud, Azure, Oracle Cloud, Alibaba Cloud, Tencent Cloud) whose reported timezone is `UTC`, `Etc/UTC` or `Etc/Unknown`. It is not guaranteed to reproduce Simple Analytics' proprietary classifier.

## Responses and CORS

| Status | Body                       | Meaning                                                                          |
| ------ | -------------------------- | -------------------------------------------------------------------------------- |
| 200    | `ok`                       | JSON collector receipt or `/healthz` liveness response                           |
| 200    | GIF bytes                  | Pixel receipt or `/r` check-in                                                   |
| 204    | Empty                      | OPTIONS preflight, or `/r` without `h` or with a bot user agent                  |
| 304    | Empty                      | `/r` with an `If-Modified-Since` value from the current UTC day                  |
| 400    | `POST required`            | `/events` or `/append` was called with a method other than POST or OPTIONS       |
| 400    | `invalid JSON`             | POST body did not parse as a non-null JSON object; arrays are not a batch format |
| 400    | `hostname is required`     | No usable hostname                                                               |
| 400    | `unsupported type: <type>` | Row type is not supported                                                        |
| 400    | `event name is required`   | Event name is missing or empty after cleaning                                    |
| 400    | `GET required`             | `/r` was called with a method other than GET or OPTIONS, or `/latest.js`, a pixel or `/healthz` with one other than GET, HEAD or OPTIONS |
| 413    | `payload too large`        | `/events` or `/append` body is over 64 KiB                                       |

An array is not processed as a batch: it returns `invalid JSON`. Collector errors are plain text, not the product API's JSON error envelope. Pixel responses use `image/gif`, `Cache-Control: no-store, no-cache, must-revalidate` and `Expires: 0`; JSON ingestion responses use `text/plain; charset=utf-8` and `Cache-Control: no-store`. `/r` responses use `Cache-Control: private, max-age=<seconds until the next UTC midnight>` and a `Last-Modified` value that encodes the visit days, not a modification time. Send new collector requests to `https://clicktag.io` directly. Existing collector URLs remain supported without the redirects used for legacy website pages.

Collector responses and OPTIONS preflights provide `Access-Control-Allow-Origin: *`, methods `GET, POST, OPTIONS`, allowed headers `Content-Type`, and a preflight max age of `86400` seconds. They do not allow credentials or arbitrary custom request headers. This does not enable cross-origin reads of `/api/*`.

`GET /healthz` returns `ok` without checking storage or database readiness, and its response has no collector CORS headers. Use it to check that the HTTP handler responds, not that events have been stored.
