Clicktag docs

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 first: the collector ignores unregistered hostnames, even when it returns HTTP 200.

Base URL: https://clicktag.io. OpenAPI document | Install the tracker

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

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 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 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 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). 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 and use 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. 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 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). 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 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.