# Clicktag > Website and API analytics at https://clicktag.io. Register a site, install the tracking script or API middleware, and query statistics. Private reports and site management accept a signed-in user's Firebase ID token or a management key (tt_mk_...) with the needed scope; some actions, such as deleting a site, starting historical imports and connecting Google or Cloudflare, need the ID token. Public-site metadata/statistics and collection need neither. Site API keys (tt_...) only authenticate API analytics ingest. Import history only when requested, following the import and migration guides. Collector HTTP 200 does not prove persistence. Read the setup guide before changing a site. ## Documentation - [Quickstart](https://clicktag.io/docs/quickstart.md): Set up free, cookie-free website analytics: sign in with Google, register a hostname, add one latest.js script tag, verify the site automatically and read your first pageview from the stats API. - [Installation](https://clicktag.io/docs/installation.md): Install the Clicktag tracking script in plain HTML, React, Vite or the Next.js App Router: one async latest.js tag, no npm package, single-page app and hash routing, data- settings and CSP rules. - [Custom events](https://clicktag.io/docs/events.md): Track custom events and conversions for free with window.tt_event() in the browser or a JSON POST /events from your server: naming rules, metadata limits, callbacks and the events breakdown API. - [Goals and funnels](https://clicktag.io/docs/goals.md): Free goals and funnel analytics: count conversions by page or custom event, build 2 to 6 step funnels, compare conversion by referrer, entry page and country, and manage goals over the API. - [API analytics](https://clicktag.io/docs/api-analytics.md): Free API analytics for Hono, Express, Fastify, Next.js, Cloudflare Workers and Go: requests, status codes, p50, p95 and p99 latency, errors and consumers per endpoint from one middleware. - [AI crawlers](https://clicktag.io/docs/ai-crawlers.md): Track which AI and search bots crawl your site with a read-only Cloudflare token: GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot and Bingbot, verified hits per bot, page and status code, no code on your site. - [Authentication](https://clicktag.io/docs/authentication.md): Authenticate Clicktag API requests with a Firebase ID token: get an access token, see which requests need one, public versus private sites, site API keys, expiry and 401, 403 and 404 errors. - [Sites](https://clicktag.io/docs/sites.md): Register, verify, list, update and delete websites and APIs with the Clicktag Sites API: hostname rules, the 50-site limit, ownership checks by script tag, DNS TXT or file, and public visibility. - [Statistics](https://clicktag.io/docs/stats.md): Query web analytics as JSON with the Clicktag Stats API: totals, time series, live visitors, breakdowns by page, referrer, country, device and UTM, retention cohorts, overview summary, filters and error codes. - [API requests](https://clicktag.io/docs/api-requests.md): Send API request metrics to POST /api/ingest with a site API key, create and revoke keys, and query per-endpoint requests, p50, p95 and p99 latency, status codes, errors and error samples. - [Historical imports](https://clicktag.io/docs/imports.md): Import historical Simple Analytics data with the Clicktag Imports API: create, list, cancel and retry background jobs that copy pageviews and events in monthly chunks, with limits and statuses. - [Search Console](https://clicktag.io/docs/search-console.md): Connect Google Search Console to Clicktag: sync clicks, impressions, CTR, position, queries, pages, countries and devices, backfill 16 months and keep the history after Google drops it. - [Email reports](https://clicktag.io/docs/email-reports.md): Daily, weekly or monthly stats emails per recipient, with sections, time zones and one-click unsubscribe, plus API alerts by email on error rate, p95 latency and silence. - [Collector](https://clicktag.io/docs/collector.md): Send pageviews, custom events, time on page and scroll depth to the Clicktag collector over HTTP: latest.js, GIF beacons, the noscript pixel, JSON POST /events, field limits and errors. - [AI agent setup](https://clicktag.io/docs/agents.md): Let an AI coding agent install Clicktag with one setup prompt that registers the site over the API, adds one latest.js tag with data-verify, migrates Simple Analytics calls and verifies a real pageview was stored. - [Migrate from Simple Analytics](https://clicktag.io/docs/migrate-simple-analytics.md): Switch from Simple Analytics to Clicktag: keep your script settings, move sa_ globals and queues to tt_, replace Stats API calls and import historical pageviews and events. - [Troubleshooting](https://clicktag.io/docs/troubleshooting.md): Fix missing pageviews and events in Clicktag: unregistered or unverified hostnames, www versus apex, ad blockers, Do Not Track, CSP, processing delays and the 400, 401, 403, 404, 409, 429 and 500 errors. - [Live](https://clicktag.io/docs/live.md): Watch hits arrive in real time for one website or every website you own, check an install with the only-my-traffic switch, and see visitors pulse on a world map. - [Workspace overview](https://clicktag.io/docs/workspace.md): Every site you own added up: visitors, pageviews, online now, top pages and sources across the whole workspace. ## Machine-readable reference - [Complete documentation](https://clicktag.io/llms-full.txt) - [OpenAPI 3.1](https://clicktag.io/openapi.json) --- Source: https://clicktag.io/docs # Quickstart Clicktag is free and sets no cookies. Sign in with Google, register up to 50 hostnames, and paste one async `latest.js` tag: the first pageview verifies the site and shows up in your dashboard. ## 1. Register your site Register your hostname before sending traffic. The collector drops traffic for unregistered hostnames without returning an error. 1. Sign in at [/login](https://clicktag.app/login) with your Google account. 2. Navigate to [/app](https://clicktag.app/app) and click **Add site**. 3. Enter your website address, such as `example.com` or `https://www.example.com/blog`. Full URLs are reduced to their hostname; non-default ports are not supported. Each account can register up to 50 sites. All sites are private by default. You can also register sites programmatically using `POST /api/sites`. See the [Sites API Reference](https://clicktag.io/docs/sites) for request schemas. ## 2. Add the tracking script Copy the Tracking script from your site settings. It includes the hostname and a site-specific verification challenge. Place it in your shared layout, document head, or before the closing body tag. This example needs your actual `verify_token` in place of `YOUR_SITE_VERIFY_TOKEN`: ```html ``` To capture visits from users with JavaScript disabled, add an optional noscript pixel inside the HTML ``: ```html ``` Keep `loading="lazy"` before `src`: it stops the pixel from loading when JavaScript renders it, which would count the pageview twice. Keep `data-verify` in the served homepage HTML: when the first beacon arrives, Clicktag fetches `https:///`, looks for a `latest.js` tag carrying this challenge and verifies the site automatically. A generic tag without it needs [DNS or file verification](https://clicktag.io/docs/sites#verify-ownership). Existing verified sites keep collecting unchanged. Always specify `data-hostname` to avoid domain detection mismatches. Subdomains and `www` prefixes are separate hostnames and are not combined automatically. Set `data-hostname` to the exact value you registered. ## 3. Verify incoming traffic Open your site in a standard desktop or mobile web browser to verify the installation: 1. Open your browser Developer Tools and select the **Network** tab. 2. Filter requests by `clicktag`: the new tag loads from `clicktag.io`, and its beacons go to `clicktag.io`. Existing Totallytics tags keep their original hosts; filter by `totallytics` for those. 3. Verify that `latest.js` loads with HTTP 200. 4. Find the request to `simple.gif`. Check that its query includes `hostname=example.com` and `type=pageview`, and that the response is HTTP 200 with content type `image/gif`. 5. Navigate away or close the tab. The tracker may send an `/append` engagement beacon, or a `simple.gif` request with `type=append` after a single-page navigation; it is not a second pageview. A collector HTTP 200 is not proof that a row was saved: writes are asynchronous, and requests can be ignored. Check the exact page in [/app](https://clicktag.app/app), or query your site's [pages breakdown](https://clicktag.io/docs/stats). An earlier missing-registration lookup can take up to 15 seconds to expire at another edge location. Failed automatic-verification probes pause for 30 seconds before a new visit retries; use **Check verification** under **Verify another way** in Site settings > Installation to check immediately. When testing, keep these checks in mind: - Use a real browser. Headless browsers with `navigator.webdriver` enabled are marked as automated traffic; bot-marked rows do not count toward normal visitor and pageview totals. - Check browser extensions. Ad blockers and privacy tools can block analytics endpoints. - Check Do Not Track. The script honors `navigator.doNotTrack === "1"` by default and will not record visits. ## 4. Explore the API You can query public stats directly without an account using our public demo domain: ```bash curl --fail-with-body -sS --max-time 30 \ 'https://clicktag.io/api/sites/demo.bitgate.dev/overview?tz=UTC' ``` This endpoint returns JSON with `totals`, `previous`, `previous_range`, `series`, `forecast`, `live` and `granularity` fields. Note that demo traffic is synthetically generated. Public-site metadata and statistics allow unauthenticated reads. Private reports require the site owner's Firebase ID token, or a management key with the `read` scope, in the Authorization header. Import jobs remain owner-only even for public sites. Read the [Stats API Reference](https://clicktag.io/docs/stats) for detailed parameter options and the [Authentication Guide](https://clicktag.io/docs/authentication) for bearer token usage. --- Source: https://clicktag.io/docs/installation # Installation Clicktag is one async `latest.js` tag, about 4 KB compressed, with no npm package, for plain HTML, React with Vite or the Next.js App Router. It tracks single-page app navigation through `pushState` and `popstate` and takes its settings from `data-` attributes or `window.tt_settings`. ## Script placement Load `latest.js` once in your root document. Do not mount duplicate script tags across child views. For a new site, copy the tag from Site settings > Installation, step 1: keep both `data-hostname` and `data-verify`. In the examples below, replace `YOUR_SITE_VERIFY_TOKEN` with that site’s `verify_token`, not your account access token. ### Plain HTML Place the script in your shared template header or footer: ```html My App
``` ### React and Vite Add the script directly inside `index.html` at the project root: ```html Vite App
``` ### Next.js (App Router) Place a raw ` ``` ## Manual pageview tracking If you prefer manual control over pageviews, disable automatic collection with `data-auto-collect="false"`. When disabled, trigger pageviews using `window.tt_pageview(path, metadata)`. Calls recording an identical consecutive path are automatically suppressed. Verify that the script has loaded before calling `window.tt_pageview`: ```html ``` ## Script configuration options Configure behavior by setting data attributes on the ` ``` The default globals are `tt_settings`, `tt_event`, `tt_metadata`, and manual-mode `tt_pageview`; the default loaded guard is `tt_tt_loaded`. An occupied event function without a preload queue is not overwritten; the script warns while pageviews continue. For most settings, `window.tt_settings` overrides the equivalent attribute; `data-auto-collect="false"`, `data-retention="false"` and `data-strict-utm="true"` still take effect. Avoid conflicting configurations and set them before `latest.js` executes. Clicktag does not host the optional [automatic-events companion scripts](https://clicktag.io/docs/events#automated-event-scripts). ## Privacy and Do Not Track By default, `navigator.doNotTrack === "1"` stops normal collection after the script loads: no pageviews or events are sent, and the script does not install `tt_event` or `tt_pageview`. Separately, the collector drops pageviews, events and engagement updates whose request carries a `DNT: 1` or `X-Do-Not-Track: 1` header. `data-collect-dnt` only skips the check in the script, which never sends the collector's `collect-dnt` override, so those requests are still dropped. Keep this default to respect the visitor's setting. ## Content Security Policy (CSP) Clicktag sends pageviews, events and the `/r` returning-visitor request as `new Image()` requests. `/append` engagement updates use `navigator.sendBeacon` when the visitor leaves the page; after a single-page navigation, or when `sendBeacon` is unavailable or refuses the data, they go as an image request to `simple.gif` instead. Tags served from `clicktag.io` or `clicktag.app` send beacons to `clicktag.io`. Existing tags served from `totallytics.com` or `totallytics.app` keep sending to `totallytics.com`. If your site serves a Content Security Policy header, merge our hosts into your existing directives. Do not overwrite your wider security rules: ```text script-src 'self' https://clicktag.io; img-src 'self' https://clicktag.io; connect-src 'self' https://clicktag.io; ``` Self-hosted copies of `latest.js` downloaded before 25 September 2026 still beacon to `https://analytics.bitgate.dev`; keep that host in `img-src` and `connect-src` as well, or download the current script. The `script-src` entry must match the host in your snippet. New installations use `https://clicktag.io`; a tag loaded from `https://clicktag.app` needs that host in `script-src` instead. Legacy Totallytics tags still need their existing script host in `script-src` and `https://totallytics.com` in `img-src` and `connect-src`. A self-hosted copy of `latest.js` needs only `'self'`, but automatic verification and the setup check only recognize tags loaded from Clicktag hosts, so verify such a site by [DNS or file](https://clicktag.io/docs/sites#verify-ownership). If your policy defines `script-src-elem`, allow the tag host there as well. Inline settings, event queues, and `onload` examples also need your existing inline-code policy; prefer external app code or your framework's nonce/hash mechanism rather than adding `unsafe-inline`. Site settings > Installation, step 3, **Run setup check** fetches your homepage and checks its `Content-Security-Policy` response header against the detected tag host and beacon endpoints. Nonce/hash policies need a browser check. It does not read `` or report-only policies, and it cannot see tags injected by client-side code; with no tag in the served HTML, it checks the script host `clicktag.io`. ## Testing on localhost By default, `latest.js` extracts `location.host` when `data-hostname` is omitted. On local development environments, this results in values like `localhost:3000`. The script has no localhost exclusion and also sends from local development. A local hostname such as `localhost:3000` cannot be registered, so the collector ignores it; an explicit production `data-hostname` records local test traffic against your live site. To verify integrations, test on an explicit staging or production domain that matches a registered hostname. Do not direct localhost test traffic to your production hostname. Next, explore [Custom Events](https://clicktag.io/docs/events) or review the [Troubleshooting Guide](https://clicktag.io/docs/troubleshooting). --- Source: https://clicktag.io/docs/events # Custom Events Clicktag records custom events for free: call `window.tt_event(name, metadata)` in the browser, or send one JSON `POST /events` from any backend with no API key. Event names are cut at 256 characters and metadata at 4,096. ## Browser event tracking Record events in the browser using `window.tt_event`. To record events before `latest.js` finishes loading, declare the asynchronous buffer queue early in your document: ```html ``` Always use standard function syntax rather than an arrow function so that `arguments` is captured correctly. Keep the `q` line: `latest.js` takes over `tt_event` only when it is missing or has a `q` array, and replays the queued calls in order. Any other existing `tt_event` function stays in place, so events are not sent while pageviews continue. Clicktag consumes only its own queue; an existing Simple Analytics `sa_event` queue is left untouched. To send an action to both providers, call each provider's event API separately. ### Triggering events from UI Call `window.tt_event(name, metadata?, callback?)` inside event handlers: ```html ``` ### TypeScript definitions If you use TypeScript, augment the global `Window` interface: ```typescript declare global { interface Window { tt_event?: ( name: string, metadata?: Record, callback?: () => void, ) => void; tt_pageview?: ( path?: string, metadata?: Record, ) => void; } } export {}; ``` ## Naming conventions and normalization Use short, descriptive event names in lowercase snake_case, such as `signup_completed` or `pricing_opened`. Incoming event names undergo automated normalization: - The collector truncates names exceeding 256 characters before normalization. - Non-alphanumeric character sequences are converted to underscores. - Leading and trailing underscores are stripped. ## Event metadata rules Event metadata must be a plain JavaScript object: - The collector stores at most 4,096 characters of serialized metadata JSON. - Payloads exceeding this limit are truncated, which may result in non-parseable JSON. - Send compact, non-sensitive strings, numbers, or booleans. - In the browser, keys from `window.tt_metadata` (set before `latest.js` runs) are added to every pageview and event and replace event keys with the same name. A global function named by `data-metadata-collector` receives that merged metadata plus `type` and `event`, and the keys it returns override the merged metadata. Clicktag aggregates event counts by name. Event breakdown `visitors` counts distinct nonempty visitor hashes only. Pageview breakdowns also count the `unique` flag of rows without a hash, but the event breakdown does not, so imported events add no visitors. Last seen is the newest event in the selected range. Use the [Goals API](https://clicktag.io/docs/goals#access) to measure page or event goals and ordered funnels of 2 to 6 steps. Custom metadata filtering is not supported. Query aggregated event breakdown data using the API: ```bash curl --fail-with-body -sS --max-time 30 \ 'https://clicktag.io/api/sites/demo.bitgate.dev/breakdown?dim=events&limit=10' ``` ## Event callbacks The optional callback runs when the event's `/simple.gif` request loads or fails; a function passed as the second argument is used as the callback. It also runs at once, without a request, when the name is not a string, number or function, or when a name function throws or returns another type. A name that is empty after cleaning, such as `"!!!"`, sends nothing and never calls the callback: ```javascript window.tt_event("lead_form_submitted", { source: "nav" }, function () { console.log("Event dispatch finished"); }); ``` The callback is not a success signal; it can run even when no request was sent. It does not guarantee persistence or delivery to permanent storage. Do not gate mission-critical logic on this callback. ## Automated event scripts `latest.js` does not automatically record link clicks, downloads, or form submissions. Clicktag serves neither `/auto.js` nor `/auto-events.js`. The upstream Simple Analytics helper targets SA globals by default and does not route events to `tt_event`. Replace needed tracking with explicit `window.tt_event` calls; do not point Clicktag at `sa_event` to share that helper. ## Backend server-side events Send events directly from backend services via `POST /events` without loading client tracking scripts: Use the real incoming visitor user-agent, not a fabricated browser string. This example runs in a backend with `fetch` support: ```typescript async function recordSignup(hostname: string, visitorUserAgent: string) { if (!hostname || !visitorUserAgent) { throw new Error( "A registered hostname and the real visitor user-agent are required", ); } const response = await fetch("https://clicktag.io/events", { method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(10_000), body: JSON.stringify({ hostname, type: "event", event: "signup_completed", path: "/signup", metadata: { plan: "starter" }, ua: visitorUserAgent, }), }); const result = await response.text(); if (!response.ok) { throw new Error(`Event collector ${response.status}: ${result}`); } return result; } ``` Server-side requirements and behaviors: - Send a JSON-encoded object. Use `Content-Type: application/json` for clarity, although the handler parses JSON regardless of that header. Form-encoded bodies are not accepted. - Payload must be a single JSON object. Batch requests are not supported. - Always set `"type": "event"`. Omitting this field records the request as a standard pageview. - Returns HTTP 200 with plain text `ok` on success, or HTTP 400 plain text on malformed input. - Pass the visitor user-agent string via the `ua` field. - Country is inferred from the supplied browser IANA `timezone`, not the request IP. Pass the end user’s timezone when available; absent, unknown, or non-geographic zones remain unknown. Client IP overrides are not accepted. - Timestamps are assigned on server arrival. You cannot pass custom timestamps or backdate events. - There is no accepted user-ID or client-IP override. Visitor hashes incorporate the UTC day, site, connection IP, user-agent, and a random salt that is replaced every UTC day and deleted once the day ends, so a shared server does not recreate end-user visitor identity. Bot-marked rows are excluded from normal metrics; bot classification includes the effective user-agent and the `bot` payload field. - Assign one stable nonempty `id` per logical event. Bounded collector replay suppression and report reconciliation are not durable exactly-once delivery. Do not blindly retry after an ambiguous timeout or issue a new ID for the same event. Review the [Collector Specification](https://clicktag.io/docs/collector) for low-level protocol details. --- Source: https://clicktag.io/docs/goals # Goals and funnels Clicktag goals count the visitors who view a page or fire a custom event, and funnels chain 2 to 6 steps completed in order on the same UTC day, with conversion by referrer, entry page or country. A site can have up to 20 goals and funnels combined. Base URL: `https://clicktag.io`. [Read this page as Markdown](https://clicktag.io/docs/goals.md). ## Create a goal Open a site, choose the **Goals** tab and click **New goal**. A site without goals suggests its top events and pages; pick one to start from it. Every step matches either: - **Page**: pageviews by path. `is` matches the path with or without a trailing slash, `starts with` matches the path and every path below it (`/blog` matches `/blog/post` but not `/blogging`, and `/` matches every page), `contains` matches anywhere in the path. - **Event**: a [custom event](https://clicktag.io/docs/events) by its exact name. Add filters to narrow a step by page, source, country, device, browser, OS or UTM source, medium and campaign. Filters on different fields must all match. Two filters on the same field match either value. A step takes up to 6 filters, and a site up to 20 goals and funnels combined. Page values start with `/`, contain no `?` or `#`, and drop trailing slashes, so `/pricing/` is saved as `/pricing`. Event names use letters, digits and `_`. In a funnel, a page step with no filters matches any pageview. ## What the numbers mean | Number | Meaning | | ----------------- | ---------------------------------------------------------------------------------------- | | Visitors | Unique visitors per day: someone active on 3 days counts 3 times | | Converted visitors | Visitors with at least one matching pageview or event that day | | Conversions | Every matching pageview or event | | Conversion rate | Converted visitors divided by visitors, never above 100% | | Funnel step | Visitors who completed this step after all earlier steps, in order, on the same UTC day | | Time to convert | Median and 90th percentile time between two consecutive funnel steps | Dashboard filters apply to goals too: they narrow the pageviews and events that goals are counted over. Funnel bars show each step as a share of step 1, with the drop-off from the step before. Click a goal to break it down by referrer, entry page, country, device, browser, OS or UTM parameter. Each visitor is counted under the value of their first pageview that day. ## Coverage Goals need a visitor identity on every pageview. The collector derives one from each request; imported data has none. When less than 99% of the pageviews in a range carry an identity, the Goals tab says so: > Goals cover 72% of pageviews in this range (older imported data has no visitor identity). Changes against the previous period only show when at least 99% of that period's pageviews carry an identity too. A range without any identified pageviews shows **No visitor data in this range**. Events sent from your server with `POST /events` get an identity built from your server's IP address, not the visitor's, so they can't follow the visitor's pageviews in a funnel or inherit their referrer and entry page. ## Access Goal routes need the site owner's Firebase ID token or a [management key](https://clicktag.io/docs/authentication#management-keys), with `read` for `GET` and `manage` for changes, even for public sites. Every route, reads included, needs a verified site. Sites you don't own return `404` `unknown site`. See [Authentication](https://clicktag.io/docs/authentication). | Method | Route | Result | | ------ | ----------------------------------------- | --------------------------------------- | | GET | `/api/sites/{hostname}/goals` | List goals, oldest first | | POST | `/api/sites/{hostname}/goals` | Create a goal (`201`) | | GET | `/api/sites/{hostname}/goals/stats` | Numbers for every goal in a date range | | GET | `/api/sites/{hostname}/goals/{id}` | Read one goal | | PUT | `/api/sites/{hostname}/goals/{id}` | Replace a goal | | DELETE | `/api/sites/{hostname}/goals/{id}` | Delete a goal | | GET | `/api/sites/{hostname}/goals/{id}/stats` | Breakdown and funnel timing for one goal | ## Goal object ```json { "id": "0b7c3e52-6a0e-4f0d-9d51-3f1f5c2a9e10", "name": "Pricing to signup", "kind": "funnel", "steps": [ { "name": "Pricing", "match": "pageview", "filters": [{ "key": "page", "op": "eq", "value": "/pricing" }] }, { "name": "Signup completed", "match": "event", "filters": [{ "key": "event", "op": "eq", "value": "signup_completed" }] } ], "created_at": "2026-09-30T09:12:44.000Z", "updated_at": "2026-09-30T09:12:44.000Z" } ``` | Field | Rules | | ------------------- | -------------------------------------------------------------------------------------------------------- | | `name` | 1 to 80 characters | | `kind` | `goal` (exactly 1 step) or `funnel` (2 to 6 steps) | | `steps[].name` | Optional, up to 40 characters; stats call an unnamed step `Step n` | | `steps[].match` | `pageview` or `event` | | `filters[].key` | `page`, `referrer`, `country`, `device`, `browser`, `os`, `utm_source`, `utm_medium`, `utm_campaign`, `event` | | `filters[].op` | `eq`; `prefix` and `contains` only with `page` | | `filters[].value` | Page paths start with `/`; event names match `^[A-Za-z0-9_]{1,256}$` | An `event` step has exactly one `event` filter with `op: "eq"`; a `pageview` step cannot use the `event` key. A goal step needs at least one filter. Two identical steps in a row, or the same filter twice in a step, are rejected. A goal that no longer passes validation is listed with `"invalid": true` and left out of stats until it is saved again. ## Create and change goals ```bash curl -X POST https://clicktag.io/api/sites/example.com/goals \ -H "Authorization: Bearer $TT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Signup completed","kind":"goal","steps":[{"match":"event","filters":[{"key":"event","op":"eq","value":"signup_completed"}]}]}' ``` `POST` returns the new goal with `201`. `PUT` takes the same body, replaces the whole goal and returns it. `DELETE` returns `{"deleted": ""}`. Goals are computed at query time, so a new or changed goal applies to past data too. ## Goal stats `GET /api/sites/{hostname}/goals/stats?from=1790208000&to=1790812800&tz=Europe/Amsterdam` `from` and `to` are unix seconds with the same defaults and limits as the [statistics API](https://clicktag.io/docs/stats#date-ranges), `tz` sets the chart buckets and the previous period, and `f` takes the same [filters](https://clicktag.io/docs/stats#filters). ```json { "granularity": "day", "visitors": 2016, "previous_visitors": 1874, "coverage": { "pageviews": 5230, "identified": 5230, "previous": { "pageviews": 4870, "identified": 4870 } }, "goals": [ { "id": "7d2a8c1e-3b4f-4e6a-8c9d-0e1f2a3b4c5d", "name": "Signup completed", "kind": "goal", "conversions": 143, "converting_visitors": 118, "rate": 0.0585, "previous": { "conversions": 126, "converting_visitors": 104, "rate": 0.0555 }, "series": [ { "t": 1790208000, "conversions": 19 }, { "t": 1790294400, "conversions": 23 } ] } ], "funnels": [ { "id": "0b7c3e52-6a0e-4f0d-9d51-3f1f5c2a9e10", "name": "Pricing to signup", "kind": "funnel", "steps": [ { "name": "Pricing", "visitors": 412 }, { "name": "Signup completed", "visitors": 61 } ], "previous": [388, 52] } ], "invalid": [] } ``` - `rate` is a fraction from 0 to 1, or `null` when the range has no visitors. - `series` has one entry per hour for ranges up to 4 days, otherwise one per day, zero-filled. Funnels have no series. - The previous period is the range moved back by as many calendar days as it covers in `tz`, like `previous_range` in the [overview](https://clicktag.io/docs/stats#overview-response). `previous_visitors` and every `previous` are `null` when less than 99% of its pageviews carry an identity, or none do. ## Goal breakdown `GET /api/sites/{hostname}/goals/{id}/stats?from=1790208000&to=1790812800&dim=referrers&limit=10` `dim` is one of `referrers`, `pages` (entry page), `countries`, `devices`, `browsers`, `os`, `utm_sources`, `utm_mediums` or `utm_campaigns`. `limit` is 1 to 100, default 10. `from`, `to` and `f` work as in goal stats. Rows are sorted by visitors, most first. ```json { "dim": "referrers", "breakdown": [ { "name": "google.com", "visitors": 880, "steps": [170, 46] }, { "name": "Direct / none", "visitors": 640, "steps": [131, 22] } ], "timing": [{ "p50_s": 204, "p90_s": 3310, "n": 61 }] } ``` `visitors` counts the visitors whose first pageview that day had this value, and `steps` how many of them reached each step. `timing` has one entry per step transition, in seconds, and is `null` for goals; `p50_s` and `p90_s` are `null` when `n` is 0. ## Errors | Status | Error | | ------ | --------------------------------------------------------- | | 400 | `invalid json`, `invalid range`, `invalid filter`, `unknown dimension`, `this goal has too many filters to measure, remove some` or a validation message such as `step 2 filter 1: unknown key` | | 401 | `sign in required`, or `invalid or revoked management key` | | 403 | `Install your tracking script to verify this site first`; a management key without the needed scope gets `scope read required` or `scope manage required` | | 404 | `unknown site`, `unknown goal` | | 429 | `too many goals for this site`; a management key sending changes too fast gets `rate limited, retry in s` with `Retry-After` | | 504 | `date range too large for goals, narrow it` | --- Source: https://clicktag.io/docs/api-analytics # API analytics Clicktag tracks requests, status codes, p50/p95/p99 latency and errors per API endpoint with one middleware for Hono, Express, Fastify, Next.js, Cloudflare Workers or Go. It has no dependencies and never sends request bodies, query strings or IP addresses. ## What it measures | Metric | Details | | ---------- | ---------------------------------------------------------------------------------------------------- | | Requests | Totals per endpoint, status code, client and consumer, compared with the previous period | | Status mix | 2xx, 3xx, 4xx and 5xx over time, with 4xx and 5xx totals and the 5xx rate | | Latency | p50, p95 and p99 per endpoint, estimated from log-scale buckets to within about 4% | | Errors | 4xx and 5xx grouped per endpoint and status, with rate, last seen and recent samples | | Clients | The calling library or browser from `User-Agent`, such as `curl`, `python-requests`, `okhttp`, `chrome` or `googlebot` | | Consumers | An optional id you attach to each request, such as an account id | Endpoints are grouped by method and route template, such as `GET /users/:id`, so every user id lands in the same row. See [Route templates](#route-templates). ## Create an API key A key belongs to one site and can only send that site's API data. A [management key](https://clicktag.io/docs/authentication#management-keys) with the `ingest` scope can send data for any of your API sites and verified websites by naming the site in the `X-Totallytics-Site` header. For a standalone API, go to [/app](https://clicktag.app/app), click **Add site**, choose **API** and enter the API's hostname, such as `api.example.com`. API sites can create keys right away, without a tracking script or verification. To add API analytics to a website you already track, use that site instead; websites must be verified before they can create keys. [API-only sites](https://clicktag.io/docs/sites#api-only-sites) covers the differences. 1. Open the site's settings and go to the **API** tab. New API sites open there. 2. Click **Create key**. The label is optional. 3. Copy the key right away: it is shown once. Keys look like `tt_` followed by 48 hex characters. A site can have up to 10 active keys. A revoked key stops working within a minute. To create and revoke keys over HTTP, see [Keys](https://clicktag.io/docs/api-requests#keys). ## Install the middleware Moving from the `totallytics` package or `github.com/bitgate/totallytics-go`? It's the same middleware under a new name: install the new package and rename the imports and calls. An existing `TOTALLYTICS_API_KEY` keeps working. ```bash JavaScript npm install clicktag ``` ```bash Go go get github.com/clicktag-io/clicktag-go ``` Set the key as `CLICKTAG_API_KEY`: a secret on Cloudflare Workers (`npx wrangler secret put CLICKTAG_API_KEY`), or an environment variable on Node.js, Bun, Deno (with `--allow-env`) and Go. Without a key, the middleware does nothing. On Hono and Express, register it before your routes so it sees every request. The **API** tab in site settings shows these steps with the install command, the example for your framework and **Copy for AI agent**, which gives a coding agent the same instructions with your hostname filled in. ```ts Hono import { Hono } from "hono"; import { clicktag } from "clicktag/hono"; const app = new Hono(); app.use("*", clicktag()); app.get("/users/:id", (c) => c.json({ id: c.req.param("id") })); export default app; ``` ```ts Workers import { withClicktag } from "clicktag/workers"; export default withClicktag({ async fetch(request, env, ctx) { return new Response("hello"); }, } satisfies ExportedHandler); ``` ```ts Express import express from "express"; import { clicktag, clicktagErrors } from "clicktag/express"; const app = express(); app.use(clicktag()); app.get("/users/:id", (req, res) => { res.json({ id: req.params.id }); }); app.use(clicktagErrors()); app.listen(3000); ``` ```ts Fastify import Fastify from "fastify"; import { clicktag } from "clicktag/fastify"; const app = Fastify(); app.register(clicktag()); app.get("/users/:id", async (request) => request.params); await app.listen({ port: 3000 }); ``` ```ts Next.js // app/users/[id]/route.ts import { withClicktag } from "clicktag/next"; async function getUser( request: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; return Response.json({ id }); } export const GET = withClicktag(getUser); ``` ```go Go package main import ( "net/http" "github.com/clicktag-io/clicktag-go" ) func main() { tt := clicktag.New(clicktag.Options{}) mux := http.NewServeMux() mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(r.PathValue("id"))) }) http.ListenAndServe(":8080", tt.Middleware(mux)) } ``` | Framework | Route reported | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | Hono | The matched pattern, such as `/users/:id`, including sub-apps and `basePath`. When no route answers, the wildcard that ran, such as `/*` | | Cloudflare Workers | The raw path, which Clicktag turns into a template. Other handlers, such as `scheduled` and `queue`, pass through untouched | | Express | `req.baseUrl + req.route.path`, or the raw path when no route matched | | Fastify | The matched route, such as `/users/:id`, including plugin prefixes, or `/*` when no route matched | | Next.js | Rebuilt from `params`: `/users/42` becomes `/users/:id`, and a `[...slug]` catch-all becomes `*` | | Go | The matched `ServeMux` pattern, such as `/users/:id` for `GET /users/{id}`, or the raw path for subtree patterns like `/static/` and unmatched requests | On Express, `clicktagErrors()` is optional: placed before your own error handler, it attaches the error message to error samples, with the error name in front unless it is a plain `Error`, and passes the error on. Requests aborted before headers were sent are recorded as `499`. The package has no dependencies and runs on Node.js 18+, Bun, Deno and Workers. Requests are aggregated in memory and sent in small background batches. The middleware never throws into your code and never delays a response. - Node.js, Bun and Deno send every 10 seconds, plus a best-effort flush on `beforeExit` and `SIGTERM`. On serverless runtimes that freeze between requests, call `await middleware.flush()` yourself. - Each Workers isolate sends 5 seconds after its first unsent request, kept alive with `waitUntil`. Full batches go out right away. - Failed sends are retried with the identical batch, up to 3 attempts in total, and Clicktag counts it once. On Hono, Workers and Next.js, durations run from the middleware until your handler returns a response, so streamed bodies are not included; on Express, Fastify and Go they include writing the body. Deployed Workers only advance their clock on I/O, so purely CPU-bound handlers report about 0 ms. ### Options | Option | Default | Description | | ----------------- | ------------------------------------ | --------------------------------------------------------------------------- | | `apiKey` | `CLICKTAG_API_KEY` | String, or a function: `(c)` on Hono, `(env)` on Workers, `()` on Express, Fastify and Next.js | | `consumer` | none | Returns an opaque id for the caller; see [Consumers](#consumers) | | `route` | detected | Returns your own route template | | `ignore` | none | Returns `true` to skip a request, such as a health check | | `errorSamples` | `true` | Send individual 4xx and 5xx requests, up to 50 5xx and 20 4xx per flush | | `flushIntervalMs` | `10000` | Send interval on Node.js, Bun and Deno | | `flushDelayMs` | `5000` | Delay before the Workers send, up to `20000`. On Next.js, the minimum gap between sends, default `1000` | | `maxBatchRows` | `1000` | Metric rows per request, up to `5000` | | `endpoint` | `https://totallytics.com/api/ingest` | Published SDK default; still supported. Set `https://clicktag.io/api/ingest` to use the new origin. | | `debug` | `false` | Log diagnostics with `console.warn` | `consumer`, `route` and `ignore` receive `(c)` on Hono, `(request, env)` on Workers, `(req, res)` on Express and `(request, reply)` on Fastify. On Next.js, `consumer` and `ignore` receive `(request, response)`, and `route` takes a string or `(request)`. ```ts app.use( "*", clicktag({ consumer: (c) => c.get("account")?.id, ignore: (c) => c.req.path === "/health", }), ); ``` ### Fastify The plugin supports Fastify 4 and 5. Register it on the root instance, and its hooks cover every route in any registration order, including prefixed plugins. Durations run until the response is sent. Thrown errors are attached to error samples, requests aborted before headers were sent are recorded as `499`, and `app.close()` sends whatever is still buffered. ### Next.js Wrap each App Router route handler in `withClicktag`, on Next.js 14.2 and newer, in the Node.js or edge runtime. Next.js has no runtime API for the matched route, so the template is rebuilt from `params`. `notFound()`, `forbidden()`, `unauthorized()` and `redirect()` are recorded with the status Next.js sends, errors that carry any other `digest` string are not recorded, other errors are recorded as `500`, and all of them are rethrown. On Next.js 15.1 and newer, batches go out after the response with `after()`, at most once per `flushDelayMs`. Older versions send in the background, kept alive with `waitUntil` on Vercel and best effort on other serverless hosts. - Cached and static responses, and URLs that match no route, never reach a handler, so they are not recorded. - Next.js 14 and 15.0 have no `after()`, so GET and HEAD requests are recorded without a user agent. - Pages Router API routes are not supported. - Pass `route` when a param value can equal a static segment after it: in `app/teams/[team]/settings`, a team named `settings` is recorded as `/teams/settings/:team`. ### Go The Go module needs Go 1.22 or newer and has no dependencies. On Go 1.22, `tt.Middleware` has to wrap the `*http.ServeMux` itself; from Go 1.23 it can sit further out, as long as the middleware in between passes the request on unchanged. chi, gin and echo have their own modules, `github.com/clicktag-io/clicktag-go/chi`, `/gin` and `/echo`, which report the router's route pattern: register `clicktagchi.Middleware(tt)`, `clicktaggin.Middleware(tt)` or `clicktagecho.Middleware(tt)` before your routes. Panics are recorded as `500` and re-panicked, so your recovery middleware still handles them. `clicktag.Options` takes `APIKey`, `Endpoint`, `Consumer`, `Route`, `Ignore`, `MaxBatchRows` and `FlushInterval`, which work like the options above, plus a `Logger` for diagnostics. Batches go out every 10 seconds. Call `tt.Shutdown(ctx)` after `server.Shutdown` so the last batch is sent, and `tt.Flush(ctx)` before a serverless invocation returns. The [Go README](https://github.com/clicktag-io/clicktag-go#readme) has a quickstart for each router. ### Other frameworks On other Node.js frameworks, record each finished request with the core client: ```ts import { Clicktag } from "clicktag"; const tt = new Clicktag(); tt.record({ method, path, route, status, durationMs, userAgent, consumer }); ``` From other languages, send batches to [`POST /api/ingest`](https://clicktag.io/docs/api-requests#send-request-metrics) yourself. ## Route templates Clicktag groups requests by route template, not by raw path. With raw paths, `/users/1` and `/users/2` become separate endpoints: thousands of rows with a few requests each, and no useful percentiles. Hono, Express and Fastify report the route pattern that matched, and Next.js rebuilds it from `params`. The Workers adapter sends the raw path, and Clicktag templates it: numeric, UUID, long hex and random-token segments become `:id`, dates become `:date` and email addresses `:email`. Query strings are dropped, paths deeper than 12 segments end in `*`, and templates are cut at 256 characters. | Sent | Stored as | | ------------------------------------------------------ | ------------------- | | `/users/123/orders` | `/users/:id/orders` | | `/files/3f2a9c1e-8b4d-4c1a-9e2f-7a6b5c4d3e2f` | `/files/:id` | | `/reports/2026-09-28` | `/reports/:date` | | `/search?q=shoes` | `/search` | | `/wp-login.php`, answered with `404` | `/*` | Scanners and typos hit paths that don't exist all day. A `404` whose route still has no parameter or wildcard after templating is stored as `/*`, so that noise stays in one row instead of flooding your endpoint list. A `404` on a real template, such as `/users/:id`, keeps its route, and error samples keep the raw path. Pass `route` to send your own template, for example to split up a catch-all handler. ## Consumers Pass `consumer` to see who calls your API and who runs into errors: requests, 5xx rate and p95 latency per consumer. Return an opaque id, such as an internal account id, never an email address or API key. Ids are cut at 128 characters. Requests without one show up as **Unidentified**. In Go, set `Options.Consumer` or call `clicktag.SetConsumer(r, id)` from your auth middleware. ## What is never sent The middleware never sends request or response bodies, headers other than `User-Agent`, query strings or IP addresses. Error samples do contain the raw path and the error message. If either can hold personal data, set `errorSamples: false` in JavaScript; the Go SDK always sends them. ## The API tab Open a site and choose **API**. Only the site owner sees this tab, also on public sites. API sites open on it. - **Summary**: requests, 5xx rate, p50 and p95 latency, each compared with the same hours of the period before: Today with yesterday until the same time, the last 7 days with the 7 days before. - **Requests** chart, stacked by 2xx, 3xx, 4xx and 5xx, and a **Latency** chart with p50 and p95. Ranges up to 4 days use hourly points, longer ranges daily ones. - **Endpoints**, **Status codes**, **Clients** and **Consumers** tables. Endpoints show requests, 5xx rate, p50, p95 and p99. Click any name in these tables to filter the whole tab to it: summary, charts, the other tables and errors. Filters from different tables combine; clicking another row in the same table swaps the filter, and clicking the active row removes it. Active filters show as chips that you remove one by one or with **Clear all**. They are stored in the URL, so a filtered view survives a reload. The **Errors** panel groups every 4xx and 5xx response by status and endpoint, with its count, its rate (the share of that endpoint's requests that returned this status) and when it was last seen. Expand a row for the latest samples: time, raw path, client, consumer, duration and error message. **Filter to this error** narrows the whole tab to that status and endpoint. To get an email when an API starts failing, slows down or goes quiet, set up [API alerts](https://clicktag.io/docs/email-reports#api-alerts). ## Retention and limits | Data | Kept for | | --------------- | --------- | | Per-minute rows | 35 days | | Hourly rollups | 25 months | | Error samples | 14 days | Stats only count requests timestamped after the site was registered. Range edges more than 34 days back come from the hourly rollups and round to whole hours. | Limit | Value | | ------------------------ | --------------------------------------- | | Active keys per site | 10 | | Ingest request body | 4 MiB | | Rows per ingest request | 5,000 metric rows and 200 error samples | | Row timestamps | Up to 7 days old or 5 minutes ahead | | Route template | 256 characters, 12 segments | | Consumer id | 128 characters | | Filters per stats request | 12, each value up to 256 characters | --- Source: https://clicktag.io/docs/ai-crawlers # AI crawlers Clicktag tracks AI crawlers like GPTBot, ClaudeBot and PerplexityBot through a read-only Cloudflare token: 30 days imported on connect, then a sync every 15 minutes, with no code on your site. ## Why your tracker can't see them GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot and PerplexityBot fetch the raw HTML of a page and never run JavaScript, so the Clicktag script never fires for them and they never show up as visits. Search engine crawlers that do run it are recognized as bots by their user agent and left out of your visits too. Cloudflare sits in front of your site and sees every request, crawlers included. Clicktag reads those requests from Cloudflare's analytics and shows them on the **Crawlers** tab of your dashboard. ## Connect Cloudflare Your site must be verified in Clicktag and its domain proxied through Cloudflare, since Cloudflare only sees traffic it proxies. 1. Open your site's settings, go to **Integrations** and find **Cloudflare**. 2. Click **Create a read-only token**. Cloudflare opens its token page with **Zone Read** and **Analytics Read** already selected for all your zones. Create the token and copy it. 3. Paste it into **Paste the token** and click **Connect**. Connecting starts tracking on every verified site whose domain is in the token's zones, as long as the token can read that zone's analytics. A site matches the zone named after its hostname or a parent domain, so `blog.example.com` matches the zone `example.com`. Sites you add later get a **Start tracking** button on their Crawlers tab. Only requests to the site's own hostname and its `www` twin count: `example.com` also counts `www.example.com`, but not other subdomains in the same zone. Clicktag checks the token before saving it and stores it encrypted. It only ever shows the last 4 characters. One token covers every site you own; **Replace token** swaps it and **Disconnect** removes it. ## What counts as verified A hit is verified when Cloudflare verified the bot, in its AI Crawler, AI Search, AI Assistant or Search Engine Crawler category, or when the request came from an IP range the bot's company publishes. Clicktag checks the lists of OpenAI, Perplexity, Google, Microsoft, Apple, Common Crawl and DuckDuckGo. The IP check happens during the sync and IP addresses are never stored. Cloudflare no longer verifies PerplexityBot. On our own sites, about 90% of the PerplexityBot hits Cloudflare didn't verify came from Perplexity's published ranges, so those count as verified. Anthropic publishes no ranges, so Claude's bots only count as verified when Cloudflare verifies them. A request that claims to be a known bot, like GPTBot or Googlebot, without being verified counts as unverified. Unverified claims of GoogleOther, ExaSearchBot or Baiduspider are left out. Those hits are kept apart from the verified numbers, because anyone can put GPTBot in a user agent: on our sites, unverified hits claiming OAI-SearchBot, GPTBot, ChatGPT-User or Googlebot came from IPs outside those companies' published ranges. Bots in Cloudflare's other categories, like SEO tools, webhooks, link previews, accessibility, advertising and security bots, are left out. ## Bots we recognize | Bot | Company | Type | | --- | --- | --- | | GPTBot | OpenAI | AI training | | OAI-SearchBot | OpenAI | AI search | | ChatGPT-User | OpenAI | AI assistant | | ClaudeBot | Anthropic | AI training | | Claude-SearchBot | Anthropic | AI search | | Claude-User | Anthropic | AI assistant | | PerplexityBot | Perplexity | AI search | | Perplexity-User | Perplexity | AI assistant | | Googlebot | Google | Search engine | | GoogleOther | Google | AI training | | Bingbot | Microsoft | Search engine | | Applebot | Apple | AI search | | meta-externalagent | Meta | AI training | | meta-externalfetcher | Meta | AI assistant | | Amazonbot | Amazon | AI training | | Bytespider | ByteDance | Search engine | | CCBot | Common Crawl | AI training | | DuckDuckBot | DuckDuckGo | Search engine | | DuckAssistBot | DuckDuckGo | AI assistant | | MistralAI-User | Mistral | AI assistant | | ExaSearchBot | Exa | AI search | | Baiduspider | Baidu | Search engine | Googlebot includes Googlebot-Image, Googlebot-Video and Googlebot-News. Other bots Cloudflare verifies, such as PetalBot or YandexUserproxy, appear under the name in their user agent, with the type of their Cloudflare category. A verified request with a generic user agent shows as **Unidentified**. ## What the numbers mean - **AI crawler hits**: verified hits from AI search, AI assistant and AI training bots. - **Search engine hits**: verified hits from search engine crawlers. - **Pages crawled**: distinct paths with at least one verified hit. - **Unverified hits**: hits from requests that claim a known bot but aren't verified. Each total is compared with the same clock times moved back by as many calendar days as your range covers: today until 12:35 against yesterday until 12:35, the last 7 days against the 7 days before. The chart splits verified hits by type; search engines start hidden, click them in the legend to show them. The **Bots** panel lists every bot with its verified hits and when it was last seen, the latest hour with a verified hit. A **blocked** count shows verified hits your site answered with 401, 403 or 429, so a firewall rule or bot setting that turns crawlers away is easy to spot. An **unverified** count shows the hits that only claimed to be that bot. **Pages** lists the top 100 paths with how many different bots read each one, and **Responses** shows the status codes verified crawlers got. Click a bot or a page to filter the whole tab. Hit counts are Cloudflare's sample-adjusted estimates. ## Sync and history | Limit | Value | | --- | --- | | Sync | Every 15 minutes | | History imported on connect | Last 30 days | | Cloudflare's own retention | 31 days | | Clicktag retention | Kept after Cloudflare drops it | | Granularity | 1 hour | | Bots and pages per report | Top 100 | | Cloudflare plan | Any, including Free | Connecting imports the last 30 days, a week at a time. The Crawlers tab shows the progress and the numbers appear once the import is done. After that, a sync every 15 minutes adds the newest hours and reads the previous hour again to catch late data. **Sync now** in site settings queues one right away. Cloudflare keeps this data for 31 days. Clicktag stores it per hour, so your crawler history keeps growing after Cloudflare has dropped it. If Cloudflare stops accepting the token, the site shows **Needs attention** with the reason. Replace the token and the sync picks up where it stopped. ## Stop tracking and disconnect **Stop tracking** in a site's settings stops crawler tracking on that site and deletes its crawler history. **Disconnect** removes your Cloudflare token, stops tracking on all your sites and deletes their crawler history. Both ask before they delete anything. Tracking a site again imports the last 30 days again. ## API All routes are owner-only. `GET /api/sites/{hostname}/crawlers/status` and `GET /api/sites/{hostname}/crawlers/overview` also accept a [management key](https://clicktag.io/docs/authentication#management-keys) with `read`, and `POST /api/sites/{hostname}/crawlers/sync` one with `manage`. The other routes need the owner's Firebase ID token. Someone else's site answers `404`, and the overview and starting tracking need a [verified](https://clicktag.io/docs/sites#verify-ownership) site (`403` otherwise). - `GET`, `PUT` and `DELETE /api/cloudflare/connection`: read, save (`{ "token" }`) or remove the Cloudflare token. `GET` and `PUT` return `{ "connected": false }` or `connected: true` with `token_hint` (the last 4 characters), `zones` (how many zones the token could see when it was saved), `created_at` and `updated_at`; `DELETE` returns `{ "disconnected": true }`. - `GET /api/sites/{hostname}/crawlers/status`: connection, zone and sync state: `connected`, `linked`, `zone` (`{ id, name }`, also shown before tracking starts when the token sees one; that lookup is kept for up to 5 minutes, or until the token is saved again), `sync_state` (`backfill`, `live` or `error`), `backfill_from`, `backfill_cursor`, `synced_until`, `last_sync_at` and `last_error`. - `POST` and `DELETE /api/sites/{hostname}/crawlers/link`: start tracking a site (returns its status), or stop and delete its history (`{ "unlinked": true }`). - `POST /api/sites/{hostname}/crawlers/sync`: queue a sync (`202`); during the import it continues from where it stopped. A site in `error` gets `409` with `"revoked": true` until the token is replaced. - `GET /api/sites/{hostname}/crawlers/overview?from&to&tz`: totals, previous period, series, bots, pages and status codes. `from` and `to` are unix seconds, `to` exclusive; hits count per UTC hour, and every hour that overlaps the range counts in full. Filter with `f=bot:GPTBot`, `f=vendor:OpenAI`, `f=purpose:ai_search` or `f=path:/pricing`, one per key; `purpose` is `ai_search`, `ai_assistant`, `ai_training` or `search`. The overview returns `granularity` (`hour` for ranges up to 4 days, else `day`), `totals` and `previous` (`ai_hits`, `search_hits`, `pages`, `unverified`), `series` (`t` with verified hits per `ai_search`, `ai_assistant`, `ai_training` and `search`), `bots` (the top 100 by verified hits, with `bot`, `vendor`, `purpose`, `hits`, `pages`, `unverified`, `blocked` and `last_seen`), `pages` (the top 100 paths by verified hits, with `path`, `hits`, `bots` and `last_seen`; `pages_capped` says there are more), `statuses` (`status` and `hits`) and `synced_until`. `last_seen` is the Unix start of the latest hour with a verified hit. The full schemas are in the [OpenAPI file](https://clicktag.io/openapi.json). ### Errors | Status | Error | | ------ | ----- | | 400 | `invalid range`, `invalid filter`, `That doesn't look like a Cloudflare API token.`, `Cloudflare rejected this token. Create one with Zone Read and Analytics Read.`, `This token can't see any zones.`, `This token can list zones but can't read analytics. Add Analytics Read.`, `Connect Cloudflare first.`, `Your Cloudflare token can't see a zone for .`, `Cloudflare analytics is turned off for .` | | 401 | `sign in required`, `invalid or revoked management key` | | 403 | `Install your tracking script to verify this site first`, `management keys cannot call this endpoint`, `scope required` | | 404 | `unknown site`, `Crawler tracking is not set up for this site.`, `Cloudflare is not connected.` | | 405 | `method not allowed` on `/api/cloudflare/connection` | | 409 | `Cloudflare can no longer read this zone. Reconnect Cloudflare in site settings.` from `POST .../crawlers/sync` on a site in `error` | | 502 | `Could not reach Cloudflare. Please try again.` when Cloudflare fails or times out while saving a token or starting tracking | | 503 | `Sync queue unavailable. Please try again.`, `Linked, but sync could not start. Use Sync now to retry.` | ## FAQ ### Do I need to add code to my site? No. Clicktag reads Cloudflare's request analytics with a read-only token. Nothing changes on your site or in your Cloudflare settings. ### Does it work on the Cloudflare Free plan? Yes. The analytics Clicktag reads are available on Free zones, with 31 days of history. ### Why does Connect reject my token? The token needs Zone Read to list your zones and Analytics Read to read requests. Connect checks both and says what is wrong: Cloudflare rejected the token, the token can't see any zones, or it can't read analytics. If Cloudflare itself fails, it asks you to try again. ### Why does my site have no Cloudflare zone? The token can't see a zone for that domain. Create a token that covers it and use Replace token in the site's settings. ### What happens to my data when I disconnect? Clicktag deletes the crawler history of every site that used the token. Your web analytics are not touched. --- Source: https://clicktag.io/docs/authentication # Authentication Clicktag authenticates private reports and site management with a Firebase ID token, valid for about an hour, or a long-lived management key, both sent in `Authorization: Bearer`. Tracking through `latest.js` or `POST /events` and public site stats need no token at all. ## Authentication model Administrative endpoints and private site statistics require a valid Firebase ID token or a [management key](https://clicktag.io/docs/authentication#management-keys) in the standard Authorization header: ```text Authorization: Bearer ``` Key credential rules: Clicktag keeps the `tt_` and `tt_mk_` key prefixes and `X-Totallytics-Site` header unchanged. Existing credentials and integrations continue working. - Management endpoints accept a Firebase ID token or a [management key](https://clicktag.io/docs/authentication#management-keys) (`tt_mk_...`), never service account JSON credentials. Site API keys (`tt_...`) only authenticate [API analytics ingest](https://clicktag.io/docs/api-requests#send-request-metrics). - Simple Analytics credentials cannot authenticate Clicktag API calls. They are only used as source configuration for [historical imports](https://clicktag.io/docs/imports). - The client Firebase configuration API key is not an access token and will be rejected. ## Obtaining an access token Access tokens are short-lived JWTs (typically valid for approximately one hour). To acquire your token for development or testing: 1. Click **Sign in** in the interactive panel on this page and continue with Google. You will return here after signing in. 2. Click **Copy access token**. This copies your active Firebase ID token directly to your clipboard, refreshing credentials if necessary. ## Using tokens in shell scripts To prevent tokens from appearing in shell history or committed code, read the token into an environment variable using silent terminal input: ```bash read -rsp "Clicktag ID Token: " TT_TOKEN && export TT_TOKEN ``` Pass the variable in the Authorization header of your API requests: ```bash curl -sS --fail-with-body \ -H "Authorization: Bearer $TT_TOKEN" \ -H "Accept: application/json" \ https://clicktag.io/api/sites ``` Keep your token secure. Never include ID tokens in frontend client bundles, public repositories, or tracking script attributes. ## Management keys Management keys are long-lived owner credentials for scripts, CI and agents. One key works for every site you own, limited by the scopes you pick. Create one under **Workspace settings**, **API keys**. The secret (`tt_mk_` followed by 48 lowercase hex characters) is shown once, so store it in your secret manager right away. You can have up to 10 active keys. Send it the same way as an ID token: ```text Authorization: Bearer tt_mk_... ``` A revoked key stops working within about a minute: each server instance caches a key for up to 30 seconds, and each data center for up to 60 seconds. Revoking a management key does not revoke the site API keys it created; the site's key list shows which management key created each one. ### Scopes | Scope | Allows | |---|---| | `read` | `GET` requests: sites, summaries, overview, breakdown, retention, setup checks, goals, email reports, report defaults and the workspace digest, alert rules, key lists, API analytics stats, Search Console stats, AI crawler stats and import jobs | | `manage` | Changes: create, update and verify sites; create, update and delete goals, email reports and alert rules; update report defaults and the workspace digest; start a Search Console or AI crawler sync; create, rotate and revoke site API keys; revoke other management keys | | `ingest` | `POST /api/ingest` for any of your sites, named in the `X-Totallytics-Site` header. Websites need to be verified first. | Scopes don't include each other: a key with only `manage` can create a site but not list your sites. Endpoints that need you signed in are listed under [What a management key cannot do](https://clicktag.io/docs/authentication#what-a-management-key-cannot-do). `GET /api/me` works with any scope, and any key can revoke itself with `DELETE /api/management-keys/self`. ### Examples Read the key into your shell once, and set `EA_HOSTNAME` to one of your sites: ```bash read -rsp "Clicktag management key: " TT_MANAGEMENT_KEY && export TT_MANAGEMENT_KEY EA_HOSTNAME='shop.example.com' ``` Then send it with each request: ```curl # List every site you own curl -H "Authorization: Bearer $TT_MANAGEMENT_KEY" https://clicktag.io/api/sites ``` ```curl # Create a site curl -X POST https://clicktag.io/api/sites \ -H "Authorization: Bearer $TT_MANAGEMENT_KEY" \ -H "Content-Type: application/json" \ -d '{"hostname":"shop.example.com"}' ``` ```curl # Mint a per-site key (the secret is only in this response) curl -X POST "https://clicktag.io/api/sites/$EA_HOSTNAME/keys" \ -H "Authorization: Bearer $TT_MANAGEMENT_KEY" \ -H "Content-Type: application/json" \ -d '{"label":"Production"}' ``` ```curl # Read the last 7 days from=$(( $(date +%s) - 7 * 86400 )) curl -H "Authorization: Bearer $TT_MANAGEMENT_KEY" \ "https://clicktag.io/api/sites/$EA_HOSTNAME/overview?from=$from" ``` ```curl # Send API analytics for one of your sites curl -X POST https://clicktag.io/api/ingest \ -H "Authorization: Bearer $TT_MANAGEMENT_KEY" \ -H "X-Totallytics-Site: $EA_HOSTNAME" \ -H "Content-Type: application/json" \ -d @batch.json ``` The batch format is described in [Send request metrics](https://clicktag.io/docs/api-requests#send-request-metrics). ### What a management key cannot do The endpoints below need you signed in. With a key they answer `403` `{"error":"management keys cannot call this endpoint"}`. - Deleting a site (`DELETE /api/sites/{hostname}`) - Google connections (everything under `/api/import-oauth/google/`) - Starting, cancelling and retrying imports (`POST .../imports`, `.../imports/{jobId}/cancel`, `.../imports/{jobId}/retry`) - Search Console properties, linking and unlinking (`.../gsc/properties`, `.../gsc/link`) - The Cloudflare connection and AI crawler linking and unlinking (everything under `/api/cloudflare/`, `.../crawlers/link`) - Test sends (`.../reports/{id}/test`, `.../alerts/{id}/test`, `POST /api/reports/workspace/test`) - Creating management keys Report defaults and the workspace digest work with a key: `GET` and `PUT /api/reports/defaults`, `GET` and `PUT /api/reports/workspace`, and `GET /api/reports/workspace/preview`. Live visitor routes (`/api/live/...`) take only an ID token; a key gets `401` `{"error":"sign in required"}`. Alert rules created with a key must include `recipients`, because a key has no account email to default to. ### Errors and limits | Status | Body | Meaning | |---|---|---| | 401 | `{"error":"invalid or revoked management key"}` | Unknown or revoked key | | 403 | `{"error":"scope manage required"}` | The key lacks the scope this endpoint needs (`read`, `manage` or `ingest`) | | 403 | `{"error":"management keys cannot call this endpoint"}` | The endpoint needs you signed in | | 403 | `{"error":"verify this site before sending API data"}` | Ingest for a website that isn't verified yet | | 400 | `{"error":"X-Totallytics-Site header required"}` | Ingest with a management key needs the site header | | 429 | `{"error":"rate limited, retry in 1s"}` | Too many changes from one key: a burst of 60, then one per second, counted on each server instance. `Retry-After` is `1` | Reads and `POST /api/ingest` are not rate limited. `GET /api/me` returns the key's scopes and your site count; use it to check a credential before a deploy. ## Endpoint authorization rules Different Clicktag endpoints enforce distinct access rules: - Public collection (`latest.js`, `simple.gif`, `noscript.gif`, `/r`, `POST /events`, `POST /append`): No token required. Collector CORS allows any origin; storing website traffic requires a registered, verified hostname. - Public site reads (`GET /api/sites/`, `/api/sites//overview`, `/api/sites//overview/summary`, `/api/sites//breakdown`, and `/api/sites//retention`): No token required for a verified site whose owner has enabled public visibility. - Private site metadata and web statistics: Send the owner's Firebase ID token or a management key with `read`. Missing or invalid tokens, non-owner tokens and unknown sites all return `404 unknown site`. A management key is checked first, on public sites too: an unknown or revoked key returns `401`, a key without `read` returns `403`. An owner can read unverified site metadata, but web statistics return `403` until verification. - Import source discovery requires sign-in. Imports, goals, Search Console, AI crawlers, email reports, alerts, site API keys and API request reports remain owner-only even when the site is public. Verification requirements depend on the endpoint; [API-only sites](https://clicktag.io/docs/sites#api-only-sites) can ingest and read request metrics before verification. - API analytics ingestion (`POST /api/ingest`): Requires a site API key (`tt_...`), not a Firebase ID token. The key can only write request metrics for its own site. A management key with `ingest` can write for any of your sites named in the `X-Totallytics-Site` header. - Account-wide site lists and summaries require sign-in or a management key and return only the caller's sites. - Site management (`GET /api/sites`, `POST /api/sites`, `PATCH /api/sites/`, `DELETE /api/sites/`): Requires a valid ID token belonging to the site owner, or a management key (`read` for `GET`, `manage` for `POST` and `PATCH`). Deleting a site needs the ID token. ### Error responses Authentication errors return standard JSON payloads accompanied by a `Cache-Control: no-store` header: ```json { "error": "sign in required" } ``` Expected status codes: - `401 Unauthorized`: An owner-only or account endpoint needs a valid token or management key. `/api/ingest` instead returns an API-key error when its site key is missing, malformed, invalid or revoked. - `403 Forbidden`: The caller owns the site, but the operation requires domain verification. Follow the response's `error` message and [verify ownership](https://clicktag.io/docs/sites#verify-ownership). Management keys also get `403` for a missing scope; see [Errors and limits](https://clicktag.io/docs/authentication#errors-and-limits). - `404 Not Found`: The hostname does not exist or the caller cannot access it. Private metadata and web-statistics reads also use this status for missing, expired or malformed tokens. ## Token expiration and retries Tokens expire after about one hour, and there is no product token-refresh endpoint. For scripts and CI, create a [management key](https://clicktag.io/docs/authentication#management-keys) instead of refreshing ID tokens. For an existing signed-in session: 1. If a request returns HTTP 401, get a fresh ID token from your signed-in Firebase client session (`user.getIdToken(true)`) or use **Copy access token** again. The Firebase Admin SDK is not a refresh mechanism for a user’s ID token. 2. Re-run safe, idempotent requests (such as `GET` queries) using the new token. 3. Do not retry state-mutating requests (`POST`, `DELETE`) blindly without checking site state first. ## Cross-Origin Resource Sharing (CORS) Actual administrative and statistics responses (`https://clicktag.io/api/*`) do not include CORS headers. The global OPTIONS handler does answer preflights, but that does not make the API cross-origin readable. Browser applications cannot query the API across origins, even for public sites. Make all API calls from backend services, serverless functions, or from within the Clicktag web origin. Review the [Stats API Reference](https://clicktag.io/docs/stats) for querying overview charts and breakdowns. --- Source: https://clicktag.io/docs/sites # Sites API The Clicktag Sites API registers, verifies, lists and updates the hostnames you track, with a Firebase ID token or a management key. Each account holds up to 50 sites, each a website or an API, and every site stays private until you make it public. Base URL: `https://clicktag.io`. [Download the OpenAPI document](https://clicktag.io/openapi.json). 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`. ## Access Site management uses a Firebase ID token in `Authorization: Bearer `. Copy a token from [Authentication](https://clicktag.io/docs/authentication), then set `TT_TOKEN` in your terminal. Site API keys (`tt_...`) only authenticate [API analytics ingest](https://clicktag.io/docs/api-requests#send-request-metrics) and cannot manage sites. Management keys (`tt_mk_...`) can; see [Management keys](https://clicktag.io/docs/authentication#management-keys). | Method | Route | Access | | ------ | ----------------------------- | ---------------------------------------- | | GET | `/api/sites` | Signed-in account; returns its own sites | | POST | `/api/sites` | Signed-in account | | GET | `/api/sites/{hostname}` | Public site, or its signed-in owner | | PATCH | `/api/sites/{hostname}` | Site owner | | DELETE | `/api/sites/{hostname}` | Site owner | | POST | `/api/sites/{hostname}/verify` | Site owner | | GET | `/api/sites/{hostname}/setup-check` | Site owner | Responses are JSON with `Cache-Control: no-store`. Call the product API from a server or the Clicktag origin: `/api/*` responses do not provide cross-origin CORS headers. Use the normalized hostname returned at registration in subsequent paths; path lookups do not lowercase it for you. ## List your sites `GET /api/sites` Returns every site owned by the authenticated account: pinned sites first, most recently pinned first, then the rest by creation time. There are no pagination parameters; an account can register at most 50 sites. ```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 ``` ```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", { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000), }); if (!response.ok) { throw new Error(`Sites ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` The response is `{ "sites": [...] }`, with an empty array when the account has no sites. Each entry contains: | Field | Type | Meaning | | -------------- | --------------- | ------------------------------------------------------ | | `hostname` | string | Registered hostname | | `owner_uid` | string | Owning account's Firebase user ID | | `display_name` | string | Display name; initially an empty string | | `is_public` | boolean | Whether anonymous metadata and stats reads are allowed | | `verified_at` | string or `null` | ISO 8601 verification timestamp; `null` until verified | | `created_at` | string | ISO 8601 creation timestamp | | `kind` | string | `web` or `api`, fixed at registration; see [API-only sites](#api-only-sites) | | `pinned_at` | string or `null` | ISO 8601 time you pinned the site; `null` when it is not pinned | For daily traffic and live counts across your sites, use [All-sites summary](https://clicktag.io/docs/stats#all-sites-summary). ## Register a hostname `POST /api/sites` Send a JSON object with `hostname` as a string. The server trims whitespace, lowercases it, converts international names to punycode and drops a trailing dot. The normalized hostname must contain a dot, use letters, digits and hyphens in labels of 1-63 characters, and be at most 253 characters overall. No label can begin or end with a hyphen, and names made only of digits and dots, such as IP addresses, are rejected. A full `https://` URL is reduced to its hostname; credentials, a non-default port or a scheme other than `http` or `https` fail with `400`. Registration creates a private, **unverified** site with an empty display name. Hostnames are unique across accounts. The account limit is 50 sites. Set `kind` to `api` to register an API instead of a website. It defaults to `web` when absent or `null`, any other value fails with `400`, and it cannot be changed later. A new website starts unverified and does not collect data until ownership is verified (see [Verify ownership](#verify-ownership)). In the normal case that happens automatically on the first visit once the site-specific tag from settings is installed. An API site can create keys and receive API analytics right away; see [API-only sites](#api-only-sites). Replace `your-domain.example` with your hostname. The write examples on this page are templates; the public demo is for reads only. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST https://clicktag.io/api/sites \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \ -H 'Content-Type: application/json' \ --data '{"hostname":"your-domain.example"}' ``` ```typescript const token = process.env.TT_TOKEN; const hostname = process.env.EA_HOSTNAME; if (!token || !hostname) throw new Error("Set TT_TOKEN and EA_HOSTNAME"); const response = await fetch("https://clicktag.io/api/sites", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ hostname }), signal: AbortSignal.timeout(20_000), }); if (!response.ok) { throw new Error(`Register ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Success is `201` with `{ "hostname": "your-domain.example", "verified_at": null, "verify_token": "tt-verify-...", "kind": "web" }`. Keep the `verify_token`; you need it to prove ownership next. Only `hostname` and `kind` are read. Registering a hostname your account already owns returns `200` with the stored registration, including its original `kind`. ## Verify ownership `POST /api/sites/{hostname}/verify` The website collector only stores data for **verified** sites, and only a verified site can be made public. API sites do not need verification for API analytics; see [API-only sites](#api-only-sites). **Automatic (default).** Copy the Tracking script from site settings. Its `data-verify` must equal the current site's `verify_token`, and `data-hostname` must match the registered hostname. When the first beacon arrives, Clicktag fetches `https://{hostname}/` and checks that actual script tag in the served HTML. Successful verification keeps that first visit. Up to five HTTPS redirects within the registered hostname and its `www.` twin (the same name with `www.` added or removed) are followed; other hosts, ports and credentials are rejected. Failed probes pause for 30 seconds. A generic tag, a noscript pixel alone, commented examples, and another registration’s token do not prove ownership. Already-verified sites continue collecting without snippet changes. The verification challenge is public by design; never put your Firebase access token in the tag. **Manual fallback.** Use this when the site-specific tag is absent from the served HTML (generic tag, client-side injection, self-hosted script) or the origin blocks our fetcher. Publish the returned `verify_token` with either method; one passing is enough. Verification record and file names retain the legacy `totallytics` identifier for compatibility. **Method A: DNS TXT record.** Publish a TXT record at `_totallytics-verify.{hostname}` with the value `totallytics-verify={verify_token}`. DNS changes can take a few minutes to propagate. **Method B: well-known file.** Serve the `verify_token` as the entire body of `https://{hostname}/.well-known/totallytics-verify.txt` over HTTPS with a `200` status. Redirects are not followed, surrounding whitespace is ignored and the file can be at most 8 KB. ```curl curl --fail-with-body --silent --show-error --max-time 30 \ -X POST https://clicktag.io/api/sites/your-domain.example/verify \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" ``` ```typescript const token = process.env.TT_TOKEN; const hostname = process.env.EA_HOSTNAME; if (!token || !hostname) throw new Error("Set TT_TOKEN and EA_HOSTNAME"); const response = await fetch( `https://clicktag.io/api/sites/${encodeURIComponent(hostname)}/verify`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(30_000), }, ); console.log(response.status, await response.json()); ``` On success the response is `200` with `verified_at` set. While no method is detected, the response is `422` with per-method `tag`, `dns` and `file` details; publish the proof and retry. An already-verified site returns `200` with `already: true`. Each hostname gets one manual check about every 10 seconds; calling sooner returns `429` with `Retry-After`. ## API-only sites Register with `"kind": "api"` when you send API request metrics from a server and have no website or tracking tag. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST https://clicktag.io/api/sites \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \ -H 'Content-Type: application/json' \ --data '{"hostname":"api.your-domain.example","kind":"api"}' ``` ```typescript const token = process.env.TT_TOKEN; const hostname = process.env.EA_HOSTNAME; if (!token || !hostname) throw new Error("Set TT_TOKEN and EA_HOSTNAME"); const response = await fetch("https://clicktag.io/api/sites", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ hostname, kind: "api" }), signal: AbortSignal.timeout(20_000), }); if (!response.ok) { throw new Error(`Register ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` | Capability | API site (`api`) | Website (`web`) | | ---------------------------------------------- | ------------------------- | ------------------ | | Create API keys | Right after registration | After verification | | Website collector, reports and imports | After verification | After verification | | Public sharing, email reports, Search Console | After verification | After verification | Create keys in site settings under **API**, or with [`POST /api/sites/{hostname}/keys`](https://clicktag.io/docs/api-requests#create-a-key) and the owner's Firebase ID token. [API analytics](https://clicktag.io/docs/api-analytics) covers the middleware setup. A key only writes to its own site's API analytics, which is why API sites skip verification. On an unverified website the same call fails with `403` and `Install your tracking script to verify this site first`. API analytics reports only count requests timestamped after the site's `created_at`, for every kind. Verification stays optional for an API site and unlocks everything in the table. Without a tracking tag, publish the `verify_token` from registration as the DNS TXT record or the well-known file described in [Verify ownership](#verify-ownership), then call `POST /api/sites/{hostname}/verify`. The API view stays first after verification. ## Check your setup `GET /api/sites/{hostname}/setup-check` (owner only) fetches your homepage exactly like the auto-verifier does and reports: - `reachable` / `error`: whether the page could be fetched over HTTPS at all - `url` / `status`: the final URL (after allowed redirects within the hostname and its `www.` twin) and HTTP status - `verified_at`: the site's verification timestamp, or `null` while unverified - `tag_host`: which Clicktag host serves a script tag that reports to this site, or `null` when there is none - `tag_reports_to`: when the HTML has Clicktag tags but none report to this site, where the first one reports to (its `data-hostname`, or the page's own hostname without one); otherwise `null` - `tag_verifies`: while the site is unverified, whether a tag that reports to it carries this site's `data-verify` token, which auto-verification needs; `null` once the site is verified or when no tag reports to it - `csp`: whether your `Content-Security-Policy` allows the script, the pageview pixel (`img-src`), and engagement beacons (`connect-src`), each `allowed` / `blocked` / `unknown` with the effective directive and the fix to apply Use it when the dashboard stays empty after installing the tag. Tag presence is diagnostic, not proof of ownership; the ownership check also requires the site’s verification challenge. The CSP result checks response headers, not a full browser execution or HTML meta policy evaluation. Results are shared for 60 seconds; add `?refresh=true` to fetch the page again. Each hostname's page is fetched at most about once every 10 seconds, and a call that needs a fetch sooner gets `429` with `Retry-After`. ## Read a site `GET /api/sites/{hostname}` Public metadata needs no token. For a private site, send the owner's bearer token. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ https://clicktag.io/api/sites/demo.bitgate.dev ``` ```typescript const response = await fetch( "https://clicktag.io/api/sites/demo.bitgate.dev", { signal: AbortSignal.timeout(20_000) }, ); if (!response.ok) { throw new Error(`Site ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` ```json { "hostname": "demo.bitgate.dev", "display_name": "Demo site", "is_public": true, "created_at": "2026-09-17T17:28:42.089Z", "kind": "web" } ``` Unlike the account-wide list, this response does not include `owner_uid`. The owner also receives `verified_at`, `verify_token`, `reports_mode` and `pinned_at`. ## Update a site `PATCH /api/sites/{hostname}` | Body field | Type | Behavior | | -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | | `display_name` | string | Optional. Truncated to 128 characters; an empty string clears it. Omitting the field retains the current value; `null` and non-strings return `400 invalid display name`. | | `is_public` | boolean | Optional. `true` enables anonymous metadata and stats reads. Omitting the field retains the current setting; `null` and non-booleans return `400 invalid sharing setting`. | | `reports_mode` | string | Optional. `inherit`, `custom` or `off`; see [Per-site settings](https://clicktag.io/docs/email-reports#per-site-settings). Omitting the field retains the current mode; other values return `400 invalid reports mode`. | | `pinned` | boolean | Optional. `true` pins the site to the top of your site list; an already pinned site keeps its pin time. `false` unpins it. Omitting the field retains the current pin; `null` and non-booleans return `400 invalid pin setting`. | There is no rename or ownership-transfer field. Extra fields are ignored. An empty object keeps the current settings. Invalid JSON, `null`, arrays and other non-object bodies return `400 invalid settings`. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X PATCH https://clicktag.io/api/sites/your-domain.example \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \ -H 'Content-Type: application/json' \ --data '{"display_name":"My website","is_public":false}' ``` ```typescript const token = process.env.TT_TOKEN; const hostname = process.env.EA_HOSTNAME; if (!token || !hostname) throw new Error("Set TT_TOKEN and EA_HOSTNAME"); const response = await fetch( `https://clicktag.io/api/sites/${encodeURIComponent(hostname)}`, { method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ display_name: "My website", is_public: false }), signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Update ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Success is `200` with `hostname`, `display_name`, `is_public`, `reports_mode` and `pinned_at`. Setting `is_public` to `true` on an unverified site fails with `400`; verify ownership first. Making a verified site public exposes its metadata and web overview, breakdown and retention endpoints, not only its share-page link. Goals, API request reports, Search Console, AI crawlers, imports, keys, email reports and alerts remain owner-only. ## Delete a site `DELETE /api/sites/{hostname}` Deletes the registration together with the site's import jobs, email report subscriptions, goals, API alert rules, site API keys, Search Console link, Cloudflare crawler link and stored icon, not historical analytics. It needs the owner's ID token; a management key gets `403`. This is not a data-erasure endpoint. After cached registrations expire, new collection for the unregistered hostname is normally discarded and its reports return `404`. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X DELETE https://clicktag.io/api/sites/your-domain.example \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" ``` ```typescript const token = process.env.TT_TOKEN; const hostname = process.env.EA_HOSTNAME; if (!token || !hostname) throw new Error("Set TT_TOKEN and EA_HOSTNAME"); const response = await fetch( `https://clicktag.io/api/sites/${encodeURIComponent(hostname)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Delete ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Success is `200` with `{ "deleted": "your-domain.example" }`. A later request for an absent registration returns `404`, not a second successful deletion. ## Site icons `GET /favicons/{hostname}` returns a cached site icon without authentication, including for private sites. This is an image endpoint, not a metadata or statistics read. A successful response is `200` with the image's content type, an `ETag` and `Cache-Control: public, max-age=21600` (six hours). Unregistered or invalid hostnames and unavailable icons return an empty `404`, cached for 60 or 300 seconds depending on the failure. ## Errors and caching Errors use `{ "error": "message" }`. Private metadata and web-statistics reads return `404 unknown site` for missing or invalid ID tokens as well as non-owner tokens. A management key is checked first, on public sites too: an unknown or revoked key returns `401` and a key without `read` returns `403`. Hostname-scoped management routes first require a valid token (`401` otherwise), then return the same `404` for unknown and non-owned sites. | Status | Message | Meaning | | ------ | --------------------- | --------------------------------------------------------------------------------------- | | 400 | `Enter a valid website address, such as example.com` | Registration hostname is missing or fails validation | | 400 | `Enter a valid API hostname, such as api.example.com` | The same, when registering with `kind` set to `api` | | 400 | `kind must be web or api` | Registration `kind` is present but not `web` or `api` | | 400 | `site limit reached` | Account already has 50 registered sites | | 400 | `invalid settings` | PATCH body is invalid JSON or is not an object | | 400 | `invalid display name` | PATCH `display_name` is present but is not a string | | 400 | `invalid sharing setting` | PATCH `is_public` is present but is not a boolean | | 400 | `invalid reports mode` | PATCH `reports_mode` is present but is not `inherit`, `custom` or `off` | | 400 | `invalid pin setting` | PATCH `pinned` is present but is not a boolean | | 400 | `verify domain ownership before making the site public` | Attempted to make an unverified site public | | 401 | `sign in required` | Required token is missing, invalid or expired (management routes only) | | 401 | `invalid or revoked management key` | The management key is unknown or revoked, on every route including public reads | | 403 | `scope read required` or `scope manage required` | The management key lacks the scope; see [Errors and limits](https://clicktag.io/docs/authentication#errors-and-limits) | | 403 | `management keys cannot call this endpoint` | Deleting a site needs the owner's ID token | | 404 | `unknown site` | The registration does not exist, or you are not allowed to see it | | 404 | `not found` | No matching product API route | | 409 | `This site is already registered to another account` | Hostname belongs to another account; registering your own hostname again returns `200` | | 409 | `Site registration changed. Reload and try again.` | Registration changed while verification was running; reload before retrying | | 422 | `verification not found yet` | No verification method was detected; see the `tag`/`dns`/`file` details | | 429 | `rate limited, retry in 1s` | Too many changes from one management key; wait for `Retry-After` | | 429 | `rate limited, retry in s` | This hostname was verified or setup-checked less than 10 seconds ago; wait for `Retry-After` | | 500 | `verification token missing` | The registration has no ownership challenge for verification | | 500 | `internal error` | An unexpected server-side error prevented completion | Site lookups are cached at the edge: a verified registration for up to two minutes, a missing or unverified one for up to 15 seconds. A mutation clears the cache in the handling location, but other locations refresh within those bounds — allow for propagation when registering, verifying, changing visibility or deleting a site. `Cache-Control: no-store` applies to HTTP responses, not this internal lookup cache. Reads of a public site's overview, overview summary, breakdown and retention by anyone other than the owner are additionally edge-cached for one minute, after checking site access; cache hits carry `Cache-Control: public, max-age=14400`, so browsers can keep them for up to four hours. Only the owner's requests bypass this response cache. --- Source: https://clicktag.io/docs/stats # 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](https://clicktag.io/openapi.json) | [Authentication](https://clicktag.io/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 `, or a [management key](https://clicktag.io/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 `:` 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=:` 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=&to=&tz=` 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](https://clicktag.io/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](https://clicktag.io/docs/sites#errors-and-caching) can delay visibility changes. --- Source: https://clicktag.io/docs/api-requests # API requests Clicktag takes API request metrics at `POST /api/ingest` with a site API key: up to 5,000 metric rows and 200 error samples per batch of at most 4 MiB, deduplicated by `batch_id`. For the middleware, see [API analytics](https://clicktag.io/docs/api-analytics). Base URL: `https://clicktag.io`. [Download OpenAPI](https://clicktag.io/openapi.json) or [read this page as Markdown](https://clicktag.io/docs/api-requests.md). ## Access | Method | Route | Result | | ------ | ------------------------------------------ | --------------------------------------------- | | POST | `/api/ingest` | Send a batch with a site API key (`202`) | | GET | `/api/sites/{hostname}/keys` | List active keys | | POST | `/api/sites/{hostname}/keys` | Create a key (`201`) | | DELETE | `/api/sites/{hostname}/keys/{id}` | Revoke a key | | POST | `/api/sites/{hostname}/keys/{id}/rotate` | Replace a key with a new one (`201`) | | GET | `/api/sites/{hostname}/requests/overview` | Totals, previous period and time series | | GET | `/api/sites/{hostname}/requests/breakdown` | Endpoints, status codes, clients or consumers | | GET | `/api/sites/{hostname}/requests/errors` | 4xx and 5xx grouped per endpoint | | GET | `/api/sites/{hostname}/requests/samples` | Latest individual 4xx and 5xx requests | Site API keys (`tt_...`) only work on `/api/ingest`: they cannot read stats or manage keys. Everything else needs the site owner's Firebase ID token from [Authentication](https://clicktag.io/docs/authentication), also on public sites. Management keys (`tt_mk_...`) work too, with the `read` scope for GET requests and `manage` for the others; see [Management keys](https://clicktag.io/docs/authentication#management-keys). A missing or expired token returns `401` `sign in required`, and a hostname that is not registered to your account returns `404` `unknown site`. Responses are JSON. ## Send request metrics `POST /api/ingest` Send request counts aggregated per minute, plus optional samples of failed requests. The [middleware](https://clicktag.io/docs/api-analytics#install-the-middleware) does all of this for you; call the endpoint directly from other languages. | Header | Value | | -------------------- | ---------------------------------------------------------------------- | | `Authorization` | `Bearer tt_...`, with a key from site settings | | `Content-Type` | `application/json` | | `X-Totallytics-Site` | Only with a management key: the hostname of the site the batch is for | Instead of a site key, you can send `Bearer tt_mk_...` with a [management key](https://clicktag.io/docs/authentication#management-keys) that has the `ingest` scope, and name the site in `X-Totallytics-Site`. This works for your API sites and verified websites; the body stays the same. ```curl now=$(date +%s) curl --fail-with-body --silent --show-error --max-time 20 \ -X POST https://clicktag.io/api/ingest \ -H "Authorization: Bearer ${CLICKTAG_API_KEY:?Create a key in site settings}" \ -H 'Content-Type: application/json' \ --data @- <": }`; see [Latency buckets](#latency-buckets) | | `user_agent` | string | Optional `User-Agent` of the caller, up to 512 characters, classified into a client and version | | `consumer` | string | Optional opaque caller id, up to 128 characters | Rows that share minute, method, route, status, client, client version and consumer are merged. Counts add up across batches, so send each request once. ### Error rows | Field | Type | Rules | | ------------- | ------- | ---------------------------------------------------------------------------------- | | `ts` | integer | Unix milliseconds of the request; up to 7 days old or 5 minutes ahead | | `method` | string | As in metric rows | | `route` | string | Optional route template; falls back to `path` | | `path` | string | Raw path, up to 512 characters; query string and fragment are removed. Required without `route` | | `status` | integer | 400-599 | | `duration_ms` | number | Optional duration in milliseconds, capped at 24 hours; missing or negative is 0 | | `user_agent` | string | Optional, as in metric rows | | `consumer` | string | Optional, as in metric rows | | `message` | string | Optional error message, up to 1,000 characters | Rows that fail these rules, and rows past the first 5,000 metrics or 200 errors, are skipped and counted in `rejected`. The rest of the batch is still stored. Longer strings are cut, not rejected. Error rows are samples for debugging: they don't add to request counts and don't create error groups, so count every request in `metrics`, failed ones included. ### Latency buckets `histogram` maps a bucket index to a request count. A duration of 1 ms or less goes into bucket `0`; anything longer into: ```typescript const bucket = Math.min(Math.ceil(Math.log(ms) / Math.log(1.08)), 250); ``` Percentiles are read from these buckets and are accurate to within about 4% above 1 ms; one that lands in bucket `0` reads as `0.5`. Rows without a `histogram` count toward requests and the average duration, but not toward percentiles. Invalid bucket entries are ignored. ### Responses and retries | Status | Message | What to do | | ------ | -------------------------------------------------------- | -------------------------------------------------- | | 202 | none | Done | | 400 | `invalid JSON`, or a rule the batch breaks | Fix the batch; do not retry it | | 401 | `missing or malformed API key` | Send `Authorization: Bearer tt_...` | | 401 | `invalid or revoked API key` | Create a new key; do not retry | | 405 | `POST required` | Use `POST` | | 413 | `payload too large` | Split into smaller batches, each with a new `batch_id` | | 500 | `internal error` | Retry with the identical body | | 503 | `ingest temporarily unavailable, retry the same batch` | Retry with the identical body | The batch-level `400` messages are `body must be a JSON object`, `unsupported wire version, expected v: 1`, `batch_id must be 8-64 characters of A-Z, a-z, 0-9, _ or -`, `metrics must be an array` and `errors must be an array`. Bodies over 4 MiB return `413`. With a management key, ingest can also return `400` `X-Totallytics-Site header required` or `invalid site`, `401` `invalid or revoked management key`, `403` `scope ingest required`, `403` `verify this site before sending API data` for a website that is not verified yet, and `404` `unknown site` for a hostname that is not yours. Fix these instead of retrying. Retry `408`, `429`, any `5xx` and network errors with backoff, sending the byte-identical body with the same `batch_id`. Clicktag deduplicates on `batch_id`, so a retried batch is counted once. Never reuse a `batch_id` for different data: it would be dropped as a duplicate. ## Keys Keys are created in site settings under **API**, or with these endpoints and the owner's token. Each site can have up to 10 active keys. ### List keys `GET /api/sites/{hostname}/keys` ```curl curl --fail-with-body --silent --show-error --max-time 20 \ "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/keys" \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" ``` ```json { "keys": [ { "id": "0b6f3f7e-5d1c-4f5e-9a41-2f8c6d7e9b10", "label": "Production", "prefix": "tt_3f9a2c71", "created_at": "2026-09-26T14:02:11.482Z", "last_used_at": "2026-09-28T03:12:40.117Z", "created_with": null } ] } ``` Only active keys are listed, newest first. `prefix` is the first 11 characters of the key, so you can tell keys apart; the full key is never returned again. `last_used_at` stays `null` until a batch sent with the key is stored, and then updates at most once a minute. `created_with` holds the `id` and `label` of the management key that created the key, or `null` when it was created while signed in. ### Create a key `POST /api/sites/{hostname}/keys` The body is optional. `label` is trimmed and cut to 64 characters. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/keys" \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \ -H 'Content-Type: application/json' \ --data '{"label":"Production"}' ``` ```typescript const token = process.env.TT_TOKEN; const hostname = process.env.EA_HOSTNAME; if (!token || !hostname) throw new Error("Set TT_TOKEN and EA_HOSTNAME"); const response = await fetch( `https://clicktag.io/api/sites/${encodeURIComponent(hostname)}/keys`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ label: "Production" }), signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Create key ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Success is `201`. `secret` is the full key and is only returned here, so store it right away. ```json { "key": { "id": "0b6f3f7e-5d1c-4f5e-9a41-2f8c6d7e9b10", "label": "Production", "prefix": "tt_3f9a2c71", "created_at": "2026-09-28T09:30:00.000Z", "last_used_at": null, "created_with": null }, "secret": "tt_3f9a2c71d04e5b6a8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5" } ``` | Status | Message | Meaning | | ------ | ----------------------------------------------------------- | -------------------------------------------------------- | | 400 | `invalid request body` | The body is JSON but not an object | | 400 | `label must be text` | `label` is present but not a string | | 400 | `A site can have up to 10 active keys. Revoke one first.` | The site already has 10 active keys | | 403 | `Install your tracking script to verify this site first` | The site is a website that is not verified yet | API sites (`kind` `api`) can create keys right after registration; websites need verification first. See [API-only sites](https://clicktag.io/docs/sites#api-only-sites). ### Revoke a key `DELETE /api/sites/{hostname}/keys/{id}` ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X DELETE "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/keys/${KEY_ID:?Set KEY_ID}" \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" ``` Success is `200` with `{ "revoked": "" }`. Ingest rejects the key within a minute. An id that does not exist on this site, or is already revoked, returns `404` `unknown key`. ### Rotate a key `POST /api/sites/{hostname}/keys/{id}/rotate` Creates a new key with the same label and revokes the old one in the same step. It works on a site with 10 active keys, because the count stays the same. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/keys/${KEY_ID:?Set KEY_ID}/rotate" \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" ``` Success is `201` with the new key, its `secret` and the id of the revoked key. The secret is only returned here, and ingest rejects the old key within a minute, so deploy the new one right away. ```json { "key": { "id": "7c1e9a52-3b4d-4f6e-8a90-1b2c3d4e5f60", "label": "Production", "prefix": "tt_9d4e7a10", "created_at": "2026-10-04T09:41:12.004Z", "last_used_at": null, "created_with": null }, "secret": "tt_9d4e7a10c2b3a4f5e6d7c8b9a0f1e2d3c4b5a6f7e8d9c0b1", "revoked": "0b6f3f7e-5d1c-4f5e-9a41-2f8c6d7e9b10" } ``` An id that does not exist on this site, or is already revoked, returns `404` `unknown key`. ## Request stats All four endpoints are owner-only and take these parameters. They only count requests timestamped after the site's `created_at`. | Parameter | Default | Description | | --------- | --------------- | ------------------------------------------------------------------------------------ | | `from` | `to` - 30 days | Range start, Unix seconds | | `to` | now | Range end, Unix seconds; must be after `from`, and the range at most 400 days | | `tz` | `UTC` | Overview only: IANA timezone for series buckets and the previous period; unknown values fall back to `UTC` | | `f` | none | Filter, repeatable; see [Filters](#filters) | | `limit` | per endpoint | Maximum rows, on breakdown, errors and samples | Latency fields are milliseconds, rounded to whole numbers from 100 ms and to one decimal below. `avg_ms` is `null` without requests, and percentiles are `null` without requests that carry a `histogram`. Range edges more than 34 days back round to whole hours, because only hourly rollups are kept that long. ### Overview `GET /api/sites/{hostname}/requests/overview` ```curl curl --fail-with-body --silent --show-error --max-time 20 \ "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/requests/overview?tz=Europe/Amsterdam" \ -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" ``` ```json { "totals": { "requests": 48210, "client_errors": 1204, "server_errors": 96, "avg_ms": 38.4, "p50_ms": 21.6, "p95_ms": 142, "p99_ms": 388 }, "previous": { "requests": 45877, "client_errors": 1310, "server_errors": 141, "avg_ms": 41.2, "p50_ms": 22.3, "p95_ms": 157, "p99_ms": 420 }, "series": [ { "t": 1790460000, "s2xx": 1980, "s3xx": 12, "s4xx": 51, "s5xx": 4, "p50_ms": 21.1, "p95_ms": 139 } ], "granularity": "day", "has_data": true } ``` | Field | Meaning | | --------------- | ---------------------------------------------------------------------------------------- | | `totals` | Requests, 4xx (`client_errors`), 5xx (`server_errors`), average and percentile latency | | `previous` | The same totals for the same clock times, moved back by as many calendar days as the range covers in `tz` | | `series` | One point per bucket, with requests per status class (`s2xx` includes 1xx) and p50/p95; empty buckets have zero counts and `null` latency | | `granularity` | `hour` for ranges up to 4 days, otherwise `day`, in `tz`; `t` is the bucket start in Unix seconds | | `has_data` | Whether the site has received any API requests since registration, regardless of range and filters | ### Breakdown `GET /api/sites/{hostname}/requests/breakdown?dim=endpoints` `dim` is required. `limit` defaults to 100, at most 500. Rows are sorted by requests, highest first. | `dim` | `name` | | ----------- | ----------------------------------------------------------------------- | | `endpoints` | `METHOD route`, with separate `method` and `route` fields | | `statuses` | Status code as a string, such as `"404"` | | `clients` | Client name, such as `curl`; `unknown` without a `User-Agent` | | `consumers` | Consumer id; empty for requests without one | ```json { "rows": [ { "name": "GET /users/:id", "method": "GET", "route": "/users/:id", "requests": 20412, "client_errors": 311, "server_errors": 12, "p50_ms": 18.9, "p95_ms": 121, "p99_ms": 344 } ] } ``` ### Errors `GET /api/sites/{hostname}/requests/errors` One row per status, method and route with at least one 4xx or 5xx response. 5xx rows come first, then the most frequent. `limit` defaults to 100, at most 500. ```json { "rows": [ { "status": 500, "method": "POST", "route": "/v1/uploads", "requests": 42, "endpoint_requests": 1985, "samples": 17, "last_seen": 1790562310 } ] } ``` `requests` counts the failed requests, and `endpoint_requests` all requests to that method and route in the range, so the error rate is `requests / endpoint_requests`. Both come from metric rows, not from error samples. A `status` filter narrows the rows but not `endpoint_requests`. `samples` is the number of stored samples in the range, and `last_seen` the Unix seconds of the newest one, or `null` without samples. Samples are kept for 14 days. ### Samples `GET /api/sites/{hostname}/requests/samples?f=status:500&f=endpoint:POST%20/v1/uploads` The latest individual 4xx and 5xx requests, newest first. `limit` defaults to 20, at most 100. Filter by `status` and `endpoint` to get the samples behind an error row. ```json { "rows": [ { "ts": 1790562310482, "method": "POST", "route": "/v1/uploads", "path": "/v1/uploads", "status": 500, "duration_ms": 812.4, "client": "python-requests", "client_version": "2.32", "consumer": "acct_4821", "message": "upstream timeout" } ] } ``` `ts` is Unix milliseconds. `duration_ms` is rounded to 0.1 ms. `message` is empty unless the sender attached one. ## Filters Every request stats endpoint accepts repeated `f=:` parameters. Everything after the first colon is the value, so `f=endpoint:GET /users/:id` works; URL-encode the space. | Key | Value | Example | | ---------- | ------------------------------------------ | ------------------------------ | | `endpoint` | `METHOD route`, as in the breakdown `name` | `f=endpoint:GET%20/users/:id` | | `status` | Status code, `100` to `599` | `f=status:500` | | `client` | Client name | `f=client:curl` | | `consumer` | Consumer id; empty for requests without one | `f=consumer:` | Values of the same key match any of them; different keys must all match. Up to 12 filters, each value up to 256 characters. ## Errors Errors use `{ "error": "message" }`. | Status | Message | Meaning | | ------ | -------------------------------------------------------- | ---------------------------------------------------------- | | 400 | `invalid range` | `from` or `to` is empty, not a number or out of bounds, `from` is not before `to`, or the range exceeds 400 days | | 400 | `invalid filter` | Unknown key, empty value (except `consumer`), a status that is not a code from 100 to 599, too many filters or too long | | 400 | `dim must be endpoints, statuses, clients or consumers` | Missing or unknown breakdown `dim` | | 401 | `sign in required` | Owner token missing, invalid or expired | | 401 | `invalid or revoked management key` | The management key is unknown or revoked | | 403 | `scope read required` | A management key without `read` called a GET endpoint | | 403 | `scope manage required` | A management key without `manage` created, revoked or rotated a key | | 403 | `Install your tracking script to verify this site first` | Creating a key on a website that is not verified yet | | 404 | `unknown site` | Hostname not registered to your account | | 404 | `unknown key` | Key id not found on this site, or already revoked | | 404 | `not found` | Unknown route or method under `keys` or `requests` | | 429 | `rate limited, retry in 1s` | Too many key changes from one management key; wait the seconds in `Retry-After` | | 500 | `internal error` | Unexpected server error; retry later | --- Source: https://clicktag.io/docs/imports # Imports API The Clicktag Imports API copies historical pageviews and events from Simple Analytics into a site in monthly background chunks, up to 1,826 days per job. Starting a job needs the site owner's Firebase ID token, and each owner can start 20 per rolling 24 hours. Base URL: `https://clicktag.io`. [Download OpenAPI](https://clicktag.io/openapi.json) or [read this page as Markdown](https://clicktag.io/docs/imports.md). ## Access Starting, canceling and retrying imports need a Firebase ID token in `Authorization: Bearer `. Sign in, open [Authentication](https://clicktag.io/docs/authentication), and use **Copy access token**. Reading sources and jobs also works with a [management key](https://clicktag.io/docs/authentication#management-keys) that has the `read` scope; on the `POST` routes a management key gets `403` `management keys cannot call this endpoint`. Site API keys (`tt_...`) do not work here; SimpleAnalytics credentials are source configuration, not Clicktag authentication. `GET /api/import-sources` accepts any signed-in account or management key with `read`. Every site-specific import endpoint requires the site's owner, **even for public sites**. Authentication is checked before the site lookup. Creating an import also requires [domain verification](https://clicktag.io/docs/sites#verify-ownership); listing, inspecting, canceling and retrying jobs do not add a verification gate. | Method | Route | Result | | ------ | ---------------------------------------------- | ------------------------------------------ | | GET | `/api/import-sources` | Supported sources and configuration fields | | POST | `/api/sites/{hostname}/imports` | Validate, create and queue a job | | GET | `/api/sites/{hostname}/imports` | Latest 50 jobs for the site | | GET | `/api/sites/{hostname}/imports/{jobId}` | One job's current state | | POST | `/api/sites/{hostname}/imports/{jobId}/cancel` | Cancel a queued or running job | | POST | `/api/sites/{hostname}/imports/{jobId}/retry` | Re-queue a failed or canceled job | Responses are JSON with `Cache-Control: no-store`. Product API responses have no cross-origin CORS headers; call from a server or the Clicktag origin. Use the normalized destination hostname returned by the [Sites API](https://clicktag.io/docs/sites); path lookups do not lowercase it. TypeScript examples run server-side on Node 20+: save a snippet as `example.mts`, set its environment variables, then run `npx tsx example.mts`. No example automatically retries a POST. ## Available sources `GET /api/import-sources` returns `{ "sources": [...] }`. Each source has `id`, `label`, `description` and `configFields`. A configuration field has `key`, `label`, `type` (`text`, `password` or `date`), `required`, and optional `placeholder` and `help` strings. The current source is `simpleanalytics`, labeled **SimpleAnalytics**: | Configuration key | Type | Required | Meaning | | ----------------- | -------- | -------- | ----------------------------------------------------------------------- | | `user_id` | text | Yes | User ID from SimpleAnalytics dashboard → Account → API | | `api_key` | password | Yes | SimpleAnalytics API key | | `source_hostname` | text | Yes | Hostname registered in SimpleAnalytics; may differ from the destination | ## Start an import `POST /api/sites/{hostname}/imports` requires `source`, `config`, `start` and `end`. Set `source` to `simpleanalytics` and supply all three configuration strings above. Strings are trimmed and truncated to 512 characters; unknown configuration keys are ignored. `source_hostname` is lowercased and must be a hostname containing a dot, without a scheme, port or path. Dates are inclusive UTC dates in `YYYY-MM-DD` format. `start` must be on or after `2010-01-01`, `start <= end`, and the difference `end - start` cannot exceed 1,826 days. There is no future-end cutoff. The dates are checked before the rest of the body, so a missing or non-JSON body also returns `invalid date range`. The planner preserves the requested date boundaries. Each month has two chunks: pageviews, then events; the first chunk starts on `start` and the final month ends on `end`. Before queueing, creation checks the daily limit and the one-active-job rule, then tests the source credentials with one request for a recent pageview export. The test gives up after 20 seconds, and a failed test returns `400`. **`201` means queued, not import finished.** Check job progress and actual reports before considering the migration complete. Read credentials without putting them in shell history. Export `EA_HOSTNAME` for your registered destination, `SA_HOSTNAME` for the source, and `EA_IMPORT_START` / `EA_IMPORT_END` for your chosen dates. These examples create a real job when used with valid credentials. ```bash read -rsp "Clicktag access token: " TT_TOKEN; printf '\n' read -rsp "SimpleAnalytics user ID: " SA_USER_ID; printf '\n' read -rsp "SimpleAnalytics API key: " SA_API_KEY; printf '\n' export TT_TOKEN SA_USER_ID SA_API_KEY ``` The curl example requires Bash and `jq`; `jq --arg` safely encodes values, including quotes in credentials. ```curl set -o pipefail jq -n \ --arg user_id "${SA_USER_ID:?Set SA_USER_ID}" \ --arg api_key "${SA_API_KEY:?Set SA_API_KEY}" \ --arg source_hostname "${SA_HOSTNAME:?Set SA_HOSTNAME}" \ --arg start "${EA_IMPORT_START:?Set EA_IMPORT_START}" \ --arg end "${EA_IMPORT_END:?Set EA_IMPORT_END}" \ '{source:"simpleanalytics",config:{user_id:$user_id,api_key:$api_key,source_hostname:$source_hostname},start:$start,end:$end}' | curl --fail-with-body --silent --show-error --max-time 300 \ -X POST "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports" \ -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \ -H 'Content-Type: application/json' --data-binary @- ``` ```typescript const { TT_TOKEN, EA_HOSTNAME, SA_USER_ID, SA_API_KEY, SA_HOSTNAME, EA_IMPORT_START, EA_IMPORT_END, } = process.env; if ( !TT_TOKEN || !EA_HOSTNAME || !SA_USER_ID || !SA_API_KEY || !SA_HOSTNAME || !EA_IMPORT_START || !EA_IMPORT_END ) { throw new Error( "Set the token, destination, source credentials, hostname and dates", ); } const response = await fetch( `https://clicktag.io/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports`, { method: "POST", headers: { Authorization: `Bearer ${TT_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ source: "simpleanalytics", config: { user_id: SA_USER_ID, api_key: SA_API_KEY, source_hostname: SA_HOSTNAME, }, start: EA_IMPORT_START, end: EA_IMPORT_END, }), signal: AbortSignal.timeout(300_000), }, ); if (!response.ok) { throw new Error(`Create import ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` The response is the job object directly. Save its `id` as `EA_IMPORT_ID` to read progress. At most 20 new jobs per owner can be created in a rolling 24-hour window across all sites and statuses; the limit returns `429`. Only one `queued` or `running` job may exist per site; another creation returns `409`. Retrying the same job is not a new creation. Import creation has no request idempotency key. The sink preserves stable SA source IDs and skips existing source records across imports; overlapping live TT data has unrelated IDs and still requires a deliberate replacement cutoff. After a timeout or ambiguous response, inspect the site's jobs before attempting another POST. ## Read progress `GET /api/sites/{hostname}/imports` returns `{ "imports": [...] }`, ordered by `created_at` descending, with at most 50 entries. There are no pagination, cursor or offset parameters. `GET /api/sites/{hostname}/imports/{jobId}` returns the job object directly. To list jobs instead, remove `/{jobId}` from the request below. Source discovery uses the same bearer header with `/api/import-sources`. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \ "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}" ``` ```typescript const { TT_TOKEN, EA_HOSTNAME, EA_IMPORT_ID } = process.env; if (!TT_TOKEN || !EA_HOSTNAME || !EA_IMPORT_ID) { throw new Error("Set TT_TOKEN, EA_HOSTNAME and EA_IMPORT_ID"); } const response = await fetch( `https://clicktag.io/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports/${encodeURIComponent(EA_IMPORT_ID)}`, { headers: { Authorization: `Bearer ${TT_TOKEN}` }, signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Import ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` ## Job object Create, get, cancel and retry return this object. List entries use the same shape; `owner_uid` and `updated_at` are never returned. | Field | Type | Meaning | | --------------- | -------------- | ---------------------------------------------------------------------------------------- | | `id` | string | UUID job identifier | | `site` | string | Destination Clicktag hostname | | `source` | string | Source ID, currently `simpleanalytics` | | `source_label` | string | Source display label, currently `SimpleAnalytics` | | `status` | string | `queued`, `running`, `completed`, `failed` or `canceled` | | `range_start` | string | Requested inclusive start date, `YYYY-MM-DD` | | `range_end` | string | Requested inclusive end date, `YYYY-MM-DD` | | `chunks_total` | integer | Planned pageview and event chunks | | `chunks_done` | integer | Fully checkpointed chunks | | `rows_imported` | number | Records of checkpointed chunks sent to storage, including ones already stored: pageviews, events and duration/scroll append rows | | `rows_skipped` | number | Checkpointed source records skipped for another hostname or an event without a name | | `error` | string or null | Latest worker error, up to 500 characters; cleared when a chunk succeeds | | `config` | object | Non-password fields: `user_id` and `source_hostname`; never `api_key` | | `created_at` | string | ISO date-time of creation | | `finished_at` | string or null | ISO date-time of completion, failure or cancellation; normally null while active | Request and returned range dates use `YYYY-MM-DD`, for example `2026-08-01`. Progress updates only after a whole chunk finishes. Partial writes may not appear in counters, and repeated source rows can be skipped at insertion: these counters are neither unique pageviews nor proof of exact storage totals. A job may have an `error` while still `queued` or `running` as the worker retries: each chunk is tried up to 4 times, about a minute apart, before the job fails with the last error. A rejected API key, or any other SimpleAnalytics `4xx` except `429`, fails the job right away. Imported pageviews count toward visitors through their exported `is_unique` flags; no synthetic visitor identities are invented. Duration and scroll attach to their original pageview and inherit its date and bot status. Browser and OS labels use the same normalization as live collection; imported country retains SA's timezone-based value. ## Cancel or retry Both actions accept a POST with no body and return `200` with the job object. | Action | Allowed state | Effect | | --------- | ---------------------- | ------------------------------------------------------------------------ | | `/cancel` | `queued` or `running` | Sets status to `canceled` | | `/retry` | `failed` or `canceled` | Sets status to `queued`, clears the error and resumes from `chunks_done` | The examples cancel a job. To retry an eligible job, replace `/cancel` with `/retry` after checking its state. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST \ -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \ "https://clicktag.io/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}/cancel" ``` ```typescript const { TT_TOKEN, EA_HOSTNAME, EA_IMPORT_ID } = process.env; if (!TT_TOKEN || !EA_HOSTNAME || !EA_IMPORT_ID) { throw new Error("Set TT_TOKEN, EA_HOSTNAME and EA_IMPORT_ID"); } const response = await fetch( `https://clicktag.io/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports/${encodeURIComponent(EA_IMPORT_ID)}/cancel`, { method: "POST", headers: { Authorization: `Bearer ${TT_TOKEN}` }, signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Cancel import ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Cancellation does not roll back rows. A chunk still being exported is discarded; a chunk already being written finishes first, and the cancel request waits for it. A retry skips checkpointed chunks; the sink also skips stable SA records already inserted by a partial attempt. This does not reconcile unrelated live collector IDs or restore interrupted job counters exactly. It does not accept replacement credentials or dates; completed jobs cannot be retried. Failed and canceled jobs keep their `api_key` for 7 days, then it is deleted and retry returns `409`; start a new import instead. Cancel and retry responses use the prior job snapshot with status overrides, so `finished_at` and the counters can be stale. Poll the GET endpoint for the persisted state. ## Errors and credentials Errors use `{ "error": "message" }`. | Status | Message or condition | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid date range`, `unknown import source`, a missing field such as `User ID is required`, `Source hostname is invalid`, or a failed credential test such as `SimpleAnalytics returned 401: ` | | 401 | `sign in required`: missing, invalid or expired token; `invalid or revoked management key` | | 403 | `Install your tracking script to verify this site first`: creating an import requires a verified site; `management keys cannot call this endpoint` on the `POST` routes; `scope read required` for a key without `read` | | 404 | `unknown site`: hostname is absent or not owned by the caller; `unknown import`: job is absent or belongs to another site | | 409 | `an import is already running for this site` on create or retry, `import is not active` for cancel, `only failed or canceled imports can be retried`, or `The API key for this import was deleted. Start a new import.` for a retry after 7 days | | 429 | `too many imports today, try again tomorrow`: rolling 24-hour creation limit reached | | 500 | `internal error` | | 502 | `import queue unavailable, please try again`: the job could not be queued. Creation cancels the new job, which still counts toward the daily limit and can be retried; retry marks the job `failed` with error `import queue unavailable` | Password configuration fields are never echoed in the job's `config`. Completion removes the stored `api_key`. Failed and canceled jobs keep it for 7 days so they can be retried, then it is deleted. The non-password fields remain visible. Error text may include upstream response snippets and is not generally secret-redacted. After an enqueue error, check job state before taking another action. Compare actual data with the [Stats API](https://clicktag.io/docs/stats); use the [SimpleAnalytics migration guide](https://clicktag.io/docs/migrate-simple-analytics) to plan the wider cutover. --- Source: https://clicktag.io/docs/search-console # Search Console Clicktag syncs Google Search Console clicks, impressions, CTR, average position, queries and pages into your dashboard: a 16-month backfill, then a refresh every 6 hours, stored by Clicktag so history outlives Google's 16-month retention. Base URL: `https://clicktag.io`. [Download OpenAPI](https://clicktag.io/openapi.json) or [read this page as Markdown](https://clicktag.io/docs/search-console.md). ## Access All endpoints on this page are owner-only, even for public sites: GSC data never appears on public dashboards. They take the site owner's Firebase ID bearer token; `GET .../gsc/status`, `.../gsc/overview` and `.../gsc/breakdown` also accept a [management key](https://clicktag.io/docs/authentication#management-keys) with `read`, and `POST .../gsc/sync` one with `manage`. Someone else's site answers `404`, and overview, breakdown and linking a property need a [verified](https://clicktag.io/docs/sites#verify-ownership) site (`403` otherwise). The OAuth callback (`/api/import-oauth/google/callback`) is public; the encrypted, 10-minute-expiring state and a browser-bound cookie protect the connection. ## Connecting The OAuth dance is browser-driven and normally starts from site settings → **Integrations** → **Connect Google Search Console**. Programmatically: 1. `POST /api/import-oauth/google/start` with `{ "site": "example.com", "provider": "google-search-console", "origin": "https://clicktag.io" }` → returns `{ "url" }`; send the browser there. `provider` is optional; `google-search-console` is the only one. `origin` is optional, but must match the host receiving this request (`https://clicktag.io` or `https://clicktag.app`). Start in the same browser that completes consent so its session cookie is preserved. 2. Google returns to the existing registered OAuth callback at `https://totallytics.com/api/import-oauth/google/callback`; this address stays unchanged for compatibility. If you started on another host, it redirects back there to verify the browser session before storing an encrypted refresh token and returning to `/app/{hostname}/settings?gsc=connected`. Failures return to `?gsc=error&reason=` instead (`session`, `site`, `denied`, `configuration`, `exchange`, `refresh`, `scope`, `unavailable`, `account` or `queue`); an expired or invalid state gets a plain-text `400`. There is one Google connection per Clicktag account; reconnecting updates every site using it. A replacement account must retain access to the existing linked properties. 3. `GET /api/sites/{hostname}/gsc/properties?connection_id=...` (the `id` from `GET /api/import-oauth/google/connections`) lists the account's Search Console properties as `{ site_url, permission_level, matches }`, matches first. `matches` is `true` for `sc-domain:{hostname}` and URL-prefix properties under `https://{hostname}/`; properties the account hasn't verified are left out. 4. `POST /api/sites/{hostname}/gsc/link` with `{ "connection_id", "property" }` (an `sc-domain:` or `http(s)://` property, up to 512 characters) validates access, starts the backfill and returns `{ "linked": true, "property" }`. ## Sync behavior Linking kicks off a backfill that walks backwards month-by-month through 16 months of history, then the link flips to `live`. Every 6 hours a job refreshes the trailing 10 days, including Google's fresh data for the last 2-3 days, which later refreshes replace with final numbers. Each sync rewrites whole days: a day always reflects its newest sync, so queries and pages Google drops when it finalizes or anonymizes data disappear instead of lingering, and retries never duplicate data. `POST …/gsc/sync` resumes the saved backfill month while backfilling, or queues a recent-window refresh when live. An expired connection returns `409` until reconnected; temporary queue failures return `503` and can be retried. `sync_state` is `backfill`, `live`, or `error` (a revoked Google connection lands in `error` with `last_error` set; reconnect from settings to resume automatically without discarding synced history). `GET .../gsc/status` returns `{ "linked": false }` for a site without a link. A linked site adds `property`, `sync_state`, `backfill_cursor` (the month the backfill is on, `null` once it's done), `synced_until` (the newest day Google had data for), `last_sync_at`, `last_error` and `created_at`. ## Endpoints | Method | Route | Result | | ------ | ----- | ------ | | POST | `/api/import-oauth/google/start` | `{ url }` consent URL | | GET | `/api/import-oauth/google/callback` | OAuth redirect target (public) | | GET | `/api/import-oauth/google/connections` | `{ connections: [{ id, provider, label, created_at }] }`, newest first (tokens never returned) | | DELETE | `/api/import-oauth/google/connections/{id}` | Delete connection, revoke at Google, unlink sites and remove their synced data | | GET | `/api/sites/{hostname}/gsc/properties` | List the account's GSC properties | | POST | `/api/sites/{hostname}/gsc/link` | Link a property, start backfill | | DELETE | `/api/sites/{hostname}/gsc/link` | Unlink and remove synced Search Console data | | GET | `/api/sites/{hostname}/gsc/status` | Link + sync state | | POST | `/api/sites/{hostname}/gsc/sync` | Resume the backfill or queue a refresh (`202`) | | GET | `/api/sites/{hostname}/gsc/overview` | Totals, previous period, daily series, visits from Google search | | GET | `/api/sites/{hostname}/gsc/breakdown` | Sorted, searchable pages of `queries` / `pages` / `countries` / `devices`, with the previous period | ## Matching Search Console Numbers match the Performance report for the same dates: - Web search only, like the report's default search type. - Totals, the daily series, queries, countries and devices are counted per property; pages per page, so page rows can add up to more than the totals. - Search Console days are Pacific Time. `from`/`to` (end exclusive) map to calendar dates in `tz`, and those dates are read as Search Console days: Sep 1-7 here is Sep 1-7 there. - Domain properties cover every subdomain and protocol. Totals include all of them, as Search Console does; pages on another host show that host before the path. - Search Console's preset ranges end on the last finalized day. Ranges ending today also include fresh data, which can still change; `final_through` marks where it starts. ## Overview response `GET …/gsc/overview?from=&to=&tz=` returns: ```json { "totals": { "clicks": 512, "impressions": 39120, "ctr": 0.0131, "position": 18.4 }, "previous": { "clicks": 480, "impressions": 36600, "ctr": 0.0131, "position": 19.1 }, "through": "2025-09-28", "series": [{ "date": "2025-09-20", "t": 1758326400, "clicks": 31, "impressions": 2404, "ctr": 0.0129, "position": 17.9 }], "previous_series": [{ "date": "2025-09-13", "t": 1757721600, "clicks": 28, "impressions": 2210, "ctr": 0.0127, "position": 18.6 }], "overlay": [{ "t": 1758326400, "gsc_clicks": 31, "tt_pageviews": 24 }], "final_through": "2025-09-28" } ``` - `totals` stop at `final_through` when the range runs past it, and `through` says so (`null` when the range is complete or has no finalized day yet). `previous` covers as many days from the start of the equally-sized period directly before the range, so a change compares like with like instead of reading Google's lag as a drop. - `position` and `ctr` are impression-weighted, matching the Search Console UI. - `series` has one point per day from the start of the range to the last day Google has data for, zero-filled. `date` is the Search Console day; `t` is its midnight in `tz`. - `previous_series` is the previous period day by day, zero-filled to its end, so `previous_series[i]` is the same day of the period as `series[i]`. - `final_through` is the last day Google has finalized; later days are fresh and can still change. - `overlay` puts GSC clicks next to the same day's visits from Google search measured by the tracker (pageviews from the `google.com` source, bots and retries excluded): a quick read on how many search clicks actually land (ad blockers, bounces before load, and SERP features all eat clicks). ## Breakdown response `GET /api/sites/{hostname}/gsc/breakdown?dim=queries&from=&to=&tz=&sort=clicks&dir=desc&q=deploy&limit=25&offset=0` returns one page of rows and how many rows match: ```json { "dim": "queries", "total": 214, "through": null, "rows": [ { "name": "deploy static site", "clicks": 12, "impressions": 340, "ctr": 0.0353, "position": 6.2, "previous": { "clicks": 9, "impressions": 310, "ctr": 0.029, "position": 7.1 } } ], "anonymized": { "clicks": 41, "impressions": 5210, "ctr": 0.0079, "position": 24.6 } } ``` - `dim` is `queries`, `pages`, `countries` or `devices`. `countries` uses ISO 3166-1 alpha-3 codes, as GSC returns them. - `sort` is `clicks` (default), `impressions`, `ctr` or `position`, and `dir` is `desc` (default) or `asc`. Ties fall back to clicks, impressions, then name. - `q` keeps rows whose name contains it, ignoring case; longer values are cut to 256 characters. For countries that is the ISO code. - `limit` defaults to 100. Numbers are rounded down and kept between 1 and 500; anything else uses the default. `offset` skips rows for paging; `total` counts every matching row. - Rows stop at the overview's `through` day, and `previous` is the same row over the same days of the period before the range, or `null` when it had no impressions then: a new query, page, country or device. `through` is that day, or `null` when the whole range counts. `pages` rows use the path as `name` (`/pricing`), prefixed by the host when it isn't the site's own (`docs.example.com/pricing`; `www.` counts as the site's own), plus `url`, the full URL of the page's most-shown variant. Protocol, `#fragment` and query-string variants of a path merge into one row. GSC anonymizes rare queries, so query totals can undershoot the daily totals — that's Google's data, not a sync bug. For `dim=queries`, `anonymized` says exactly how much: the daily totals minus every query row over the same days, with its CTR and average position, whatever `q` and paging show. It's `null` when it can't be exact, such as a day the query list never synced, and on the last few days until both sides come from the same sync. ## Drill-downs Add `f=query:` or `f=page:` to either endpoint to look at one query or one page. `` is a Pages row `name`, like `/pricing`. - The overview's `totals`, `previous` and both series become that query's or page's own rows, on the same day axis. `overlay` is empty. - With `f=query:...`, the breakdown takes `dim=pages` and lists the pages that query showed, grouped like the Pages tab. With `f=page:...` it takes `dim=queries` and lists the queries that showed the page. Any other `dim` is a 400. - With `f=page:...`, `anonymized` is the page's own clicks and impressions minus its listed queries, so the listed rows plus `anonymized` add up to the page's row exactly. - One filter at a time. Values over 2048 characters are cut to Google's 2048. In the dashboard, clicking a query or a page opens the same drill-down with a removable filter, kept in the URL as `sq=` or `sp=`, so Back removes it and links keep it. ## Errors | Status | Error | | ------ | ----- | | 400 | `invalid range`, `unknown dim`, `unknown sort`, `unknown dir`, `one Search filter at a time`, `unknown Search filter`, `a query filter lists pages`, `a page filter lists queries`; on start `invalid site`, `unknown provider`, `invalid origin`; on properties and link `connection_id required`, `invalid property`, `the connected Google account has no access to that property`, or an expired Google connection with `"revoked": true` | | 401 | `sign in required`, `invalid or revoked management key` | | 403 | `Install your tracking script to verify this site first`, `management keys cannot call this endpoint`, `scope required` | | 404 | `unknown site` or `site not found` for someone else's site, `connection not found`, `no Search Console link` | | 409 | `POST .../gsc/sync` on a link in `error`, with `"revoked": true` | | 502 | `Google denied Search Console access. Reconnect Google and approve read-only access.`, `Google could not list your properties. Please try again.` | | 503 | `Sync queue unavailable. Please try again.`, `Property linked, but sync could not start. Use Sync now to retry.` | --- Source: https://clicktag.io/docs/email-reports # Email reports Clicktag emails daily, weekly or monthly stats digests: set workspace defaults once for every site, give any site its own settings (up to 25 recipients) or switch it off, and get one workspace digest across all sites, plus [API alerts](#api-alerts) that email when an API fails, slows down or goes quiet, checked every minute. Base URL: `https://clicktag.io`. [Download OpenAPI](https://clicktag.io/openapi.json) or [read this page as Markdown](https://clicktag.io/docs/email-reports.md). ## How the layers work | Layer | Covers | Wins when | | ----------------- | ---------------------------------------------- | ---------------------------------------------------------- | | Workspace defaults | Every verified site set to **Workspace default** | The site's mode is `inherit` (new sites start here) | | Site settings | One site, up to 25 recipients | The site's mode is `custom` | | Workspace digest | One email per recipient across all your sites | Always, independent of the site modes | A site on Custom never picks up default changes; a site on Workspace default always does. A site set to Off sends nothing, and unverified sites never send. ## Access Every route below except unsubscribe needs the owner's Firebase ID bearer token or a management key (`read` for GET, `manage` for changes); test sends need the Firebase token. The unsubscribe endpoints are public: the token in the link is the capability. | Method | Route | Result | | ------ | ----------------------------------------- | ----------------------------------------------- | | GET | `/api/reports/defaults` | Workspace defaults and which sites use them | | PUT | `/api/reports/defaults` | Save workspace defaults | | GET | `/api/reports/workspace` | Workspace digest settings | | PUT | `/api/reports/workspace` | Save workspace digest settings | | POST | `/api/reports/workspace/test` | Email the digest to yourself now | | GET | `/api/reports/workspace/preview` | Render the digest as HTML | | PATCH | `/api/sites/{hostname}` | Set `reports_mode` to `inherit`, `custom` or `off` | | GET | `/api/sites/{hostname}/reports` | Mode, site subscriptions and what the site sends | | POST | `/api/sites/{hostname}/reports` | Add a recipient (`201`) | | PATCH | `/api/sites/{hostname}/reports/{id}` | Update frequency, schedule, sections | | DELETE | `/api/sites/{hostname}/reports/{id}` | Remove a recipient | | POST | `/api/sites/{hostname}/reports/{id}/test` | Queue a test email for the current window | | GET | `/api/sites/{hostname}/reports/preview` | Render the email as HTML | | GET | `/api/reports/unsubscribe/{token}` | Confirmation page (HTML) | | POST | `/api/reports/unsubscribe/{token}` | One-click unsubscribe (HTML) | ## Cadence and windows | Frequency | Sends | Covers | | --------- | -------------------- | ------------------------- | | `daily` | every day | yesterday (local) | | `weekly` | Mondays | previous Monday to Sunday | | `monthly` | the 1st of the month | previous calendar month | `send_hour` is a local wall-clock hour (0-23) in the subscription's IANA `timezone`; daylight-saving changes follow the clock, not a fixed UTC offset, so a send hour that a spring-forward change skips sends when the clocks jump, and one that a fall-back change repeats sends once. Due reports are queued at 7 minutes past each hour. Every report covers the last *fully elapsed* period, so a weekly report mailed Monday 09:00 Europe/Amsterdam always covers the Monday-Sunday that just ended in that timezone. Timezones are stored in canonical form, so `europe/amsterdam` comes back as `Europe/Amsterdam`; fixed offsets such as `+02:00` are refused with `400` `invalid timezone`. Changes compare with the period before of the same kind: the day before, the Monday to Sunday before, or the whole previous calendar month. A top row's change uses its own count in that period, even when it wasn't a top row then. ## Sections | Key | Default | Contents | | ----------- | --------- | ----------------------------------------------------- | | `overview` | always on | Totals, change vs previous period, chart, live count | | `pages` | on | Top 5 pages with change vs previous period | | `referrers` | on | Top 5 referrers with change | | `countries` | off | Top 5 countries with change | | `events` | off | Top 5 events with change | | `alerts` | on | Spike/dip callout for abnormal days | | `api` | on | Requests, error rates, p95, top and failing endpoints | The alert section flags the most extreme day in the period whose pageviews land more than 2.5 standard deviations and more than 50% away from the trailing 28-day mean, and names the top 3 referrers of a spike day. It needs at least 14 days of history before the period, so new sites get no callout for about two weeks. The `api` section shows request volume and p95 latency with change, 5xx and 4xx rates, the top 5 endpoints and the endpoints with the most 5xx responses. It only appears when the site received API requests in the period or the one before; a site with API traffic but no pageviews in either period gets it as the headline instead of empty web totals. ## Workspace defaults `PUT /api/reports/defaults` accepts any subset of `enabled`, `recipients` (up to 10), `frequency`, `timezone`, `send_hour` and `sections`. Changes apply on save to every site on Workspace default; each recipient gets one email per site. `GET` also returns `sites` (how many sites inherit, which are on Custom or Off), `stalled` (default recipients that failed five times in a row on an inheriting site) and `dropped` (addresses removed by an unsubscribe or a hard bounce since the last save). A save clears `dropped` and resets the failure counts of default and digest recipients, so stalled ones are retried. ```bash curl -X PUT "$TT_HOST/api/reports/defaults" \ -H "Authorization: Bearer $TT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"enabled":true,"recipients":["team@company.com"],"frequency":"weekly","timezone":"Europe/Amsterdam","send_hour":9}' ``` Send back the `updated_at` you read to avoid overwriting someone else's change: if the settings changed in between, the save returns `409`. The defaults and the digest share one version, so save one before editing the other. `POST /api/sites/{hostname}/reports` on a site that isn't set to Custom returns `409` with `switch this site to custom reports first`. ## Per-site settings `PATCH /api/sites/{hostname}` with `{"reports_mode":"custom"}` gives the site its own recipients; the first switch to Custom copies the current default recipients, schedule and sections into the site, so nothing changes until you edit them. Switching away from Custom keeps the site's own recipients for when you switch back. `GET /api/sites/{hostname}/reports` returns `mode`, the site's `subscriptions` and `effective`: who the site mails right now and when, or why it sends nothing. `POST /api/sites/{hostname}/reports` accepts `email`, `frequency`, and optional `timezone` (default `UTC`), `send_hour` (default `9`) and `sections`. The site must be verified (`403`) and set to Custom (`409`). One subscription per site, email and frequency; duplicates return `409`. At most 25 subscriptions per site, paused ones included and an address on two frequencies counting twice; the next one returns `429` with `too many recipients for this site`. Addresses you add are active right away, without a confirmation email. ```bash curl -X POST "$TT_HOST/api/sites/$TT_HOSTNAME/reports" \ -H "Authorization: Bearer $TT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"teammate@company.com","frequency":"weekly","timezone":"Europe/Amsterdam","send_hour":9}' ``` `PATCH` accepts any subset of `frequency`, `timezone`, `send_hour`, `enabled`, `sections`. Setting `enabled` to `false` pauses without deleting. Setting it back to `true` resets `fail_count` and needs a verified site. Moving an address to a frequency it already has returns `409`. `POST .../test` queues a real email to that subscription's address for the current last-full-period window and returns `202` with the window it used, or `502` when the queue is unavailable. It sends at most once per 30 seconds per subscription (`429` otherwise), and a paused subscription returns `409`. Test sends never consume or block the scheduled window; a delivered test resets `fail_count` and sets `last_sent_at`. `GET .../preview?frequency=weekly&tz=Europe/Amsterdam§ions=pages,referrers,alerts` renders the email HTML inline; omit `sections` to see everything on. `overview` is always included, unknown section keys are ignored, `frequency` defaults to `weekly` and an unknown `tz` falls back to UTC. ## Workspace digest One email per recipient with total visitors and pageviews against the previous period, a row per site with visitors in either period, the biggest movers (up to three each way among sites with at least 50 visitors in either period), the quiet sites, and one requests and 5xx line per API site. It follows the same cadence and windows as site reports. `PUT /api/reports/workspace` accepts `enabled`, `recipients` (up to 10), `frequency`, `timezone`, `send_hour` and `updated_at`, with the same rules as the defaults. `POST /api/reports/workspace/test` emails the digest for the last full period to you, the signed-in owner, at most once per 30 seconds (`429` otherwise), and returns the address and window. It needs at least one digest recipient and a verified site (`400` otherwise), though it only mails you. `GET /api/reports/workspace/preview?frequency=weekly&tz=Europe/Amsterdam` renders it as HTML. ## Delivery and unsubscribe Mail is sent from `reports@totallytics.com` over a Postmark broadcast stream with `List-Unsubscribe` and RFC 8058 one-click headers. Unsubscribing from a site's own subscription sets `enabled` to `false` and keeps history; re-enable from the site's settings. Unsubscribing from a default or digest email removes that address from that workspace list right away (a default address stops for every inheriting site); it shows up under `dropped` until the next save. A permanent failure (an invalid address, or one Postmark marks inactive, for example after a hard bounce) disables a site's own subscription immediately. On a default or digest email, an inactive address is removed from the workspace list and an invalid one counts as a failure. Transient failures retry three times, then the send is marked failed and `fail_count` rises. Recipients with five consecutive failures stop being scheduled until re-enabled or saved again. ## API alerts Per-site rules on API traffic, checked every minute. All routes need the owner's Firebase ID token or a management key (`read` for GET, `manage` for changes), except `POST .../test`, which needs the Firebase token. Adding, changing and testing rules also needs a verified site; listing and deleting don't. | Method | Route | Result | | ------ | ---------------------------------------- | ------------------------------ | | GET | `/api/sites/{hostname}/alerts` | List rules | | POST | `/api/sites/{hostname}/alerts` | Add a rule (`201`) | | PATCH | `/api/sites/{hostname}/alerts/{id}` | Pause or resume with `enabled` | | DELETE | `/api/sites/{hostname}/alerts/{id}` | Remove a rule | | POST | `/api/sites/{hostname}/alerts/{id}/test` | Email a sample alert now | | Metric | Fires when | `threshold` | | ------------ | ----------------------------------------------------------------------------- | ----------------------- | | `error_rate` | the share of 5xx responses in the window is above the threshold | percent, 0 to below 100 | | `p95_ms` | p95 latency in the window is above the threshold | ms, below 600000 | | `silence` | the window has no requests while the same window yesterday had `min_requests` | none | `window_minutes` is 5, 15 or 60, ending a minute back so in-flight batches land first. Set `method` and `route` (the template shown in the API view, such as `/v1/orders/:id`) to watch one endpoint; leave both out for the whole API. Windows with fewer than `min_requests` requests (default 20) never fire a rule, and a firing rule resolves in them with a "too few requests" email. A `p95_ms` window without latency buckets keeps the current state. `recipients` defaults to the signed-in owner's email; a management key has none, so it must send `recipients` (`400` `recipients is required when using a management key` otherwise). Up to 10 recipients per rule and 20 rules per site, after which adding one returns `429`. ```bash curl -X POST "$TT_HOST/api/sites/$TT_HOSTNAME/alerts" \ -H "Authorization: Bearer $TT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"metric":"error_rate","threshold":2,"window_minutes":15,"method":"POST","route":"/v1/orders"}' ``` A rule is `ok` or `firing`. Crossing the threshold sends one alert and marks it `firing`; nothing more is sent until it recovers, which sends one resolved email. An alert stays firing or resolved for at least 10 minutes, so a value hovering at the threshold can't flap. `last_value` holds the value behind the latest change. Pausing resets the rule to `ok`. `POST .../test` emails a sample only to you, the signed-in owner, once per 30 seconds per rule, and leaves the state alone. Alerts come from `alerts@totallytics.com` on a transactional stream with a link to the site's API view. Every firing and resolved email carries a one-click unsubscribe link (and `List-Unsubscribe` headers) for its own recipient: `GET /api/alerts/unsubscribe/{id}/{token}` shows a confirm page and `POST` removes that address from the rule, or pauses the rule when it was the last one. Report unsubscribes don't affect alerts. --- Source: https://clicktag.io/docs/collector # 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](https://clicktag.io/docs/sites#register-a-hostname) first: the collector ignores unregistered hostnames, even when it returns HTTP 200. Base URL: `https://clicktag.io`. [OpenAPI document](https://clicktag.io/openapi.json) | [Install the tracker](https://clicktag.io/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](https://clicktag.io/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](https://clicktag.io/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](https://clicktag.io/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](https://clicktag.io/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_` over ``. 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](https://clicktag.io/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](https://clicktag.io/docs/installation) and use [Events](https://clicktag.io/docs/events) for browser event calls. Fetching the script itself does not record a pageview. ### Return check `GET /r?h=` records returning visitors for [retention](https://clicktag.io/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](https://clicktag.io/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:///`, 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](https://clicktag.io/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](https://clicktag.io/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: ` | 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=` 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. --- Source: https://clicktag.io/docs/agents # Set up with an AI agent Clicktag can be set up by a coding agent: it registers your hostname over the API, adds one `latest.js` tag with its `data-verify` token and checks that a real pageview was stored. Give it repository access and a short-lived `TT_TOKEN`, then copy the setup prompt below. Machine-readable references: [documentation index](https://clicktag.io/llms.txt), [full documentation](https://clicktag.io/llms-full.txt), [OpenAPI specification](https://clicktag.io/openapi.json), and [this guide as Markdown](https://clicktag.io/docs/agents.md). ## Copy a setup prompt Replace the bracketed values. Supply the access token separately, following the authentication step below. ```text Install Clicktag in this project. Hostname: [example.com] Repository or workspace: [project location] Deployment permission: [open a PR only / deploy after checks pass] Replace an existing Simple Analytics install: [yes / no] One clearly named verification event is permitted: [yes / no] Read these first: https://clicktag.io/llms.txt https://clicktag.io/llms-full.txt https://clicktag.io/openapi.json https://clicktag.io/docs/agents.md 1. Inspect the actual app root, generated-page templates, existing analytics, event calls, script settings, CSP, and any first-party analytics proxy. Do not remove unrelated analytics or change the site's privacy settings. 2. Use TT_TOKEN from the execution environment for owner API calls. Check GET /api/sites and reuse the exact owned hostname, or register it with POST /api/sites. A 409 is not proof of ownership. Do not enable public dashboards or invent service keys. If sign-in is missing, finish safe repository work and report the account step as blocked. 3. Add https://clicktag.io/latest.js once in each shared HTML document/root, with data-hostname matching the registered hostname and data-verify set to its verify_token (GET /api/sites/{hostname} if reused). Keep production collection out of local/preview builds. Update all relevant entrypoints and generator templates, not only the homepage. 4. Preserve supported settings and migrate sa_event/sa_settings/sa_pageview calls and preload queues to tt_event/tt_settings/tt_pageview. Do not combine automatic pageviews with extra route tracking. If replacing Simple Analytics, swap its core tag and optional pixel. Audit auto-events helpers and proxy destinations separately; SA helpers target SA by default. 5. Merge the analytics origin into script-src, connect-src and img-src; also update script-src-elem when present. Preserve the existing policy. 6. Run the project's build/checks. Deploy only with the permission above; otherwise open a PR and leave production verification explicitly pending. 7. On the deployed registered hostname, verify a real browser's pageview request and the matching stored data. If permitted, send one unique verification event and poll authenticated breakdown reads for its name. Do not repeatedly send events, fake a non-bot result, or treat HTTP 200 as proof of ingestion. Report a missing real-browser check as a blocker. 8. Report hostname/ownership, changed files, build result, PR or deployment, request destination, stored-data evidence and any unfinished steps. Treat historical migration as a separate task, not a result of adding a tag. If history is requested, read /docs/imports and the migration guide first. Confirm a non-overlapping UTC range, source credentials and existing data before creating a job. Verify stored counts, not just its completed status. ``` ## From the dashboard Site settings > Installation (or the API tab for API analytics) has **Copy for AI agent**. It copies a brief for this site: hostname, the exact tag or middleware code, where it goes, how verification works and the dashboard page that confirms the first data. It follows this guide; hand the access token over separately as described below. In API briefs, `` stands for the key the site owner creates in Site settings > API. ## 1. Sign in and confirm the site Sign in to [Clicktag](https://clicktag.app/login), reopen [Authentication](https://clicktag.io/docs/authentication), and click **Copy access token**. Pass that short-lived Firebase user ID token to the agent's server-side environment as `TT_TOKEN`. Copy a fresh token if it expires. For a long-lived credential, create a [management key](https://clicktag.io/docs/authentication#management-keys) with the `read` and `manage` scopes and pass it as `TT_TOKEN` instead; it covers the API calls in this guide. Starting a historical import still needs the signed-in token. The token is for site management and private stats reads, not the tracking tag. Keep it out of source files, browser bundles, and public environment variables such as `VITE_*` or `NEXT_PUBLIC_*`. Choose the exact hostname before editing. Use `example.com`, not `https://example.com/path` or a hostname with a port. Registration trims whitespace and lowercases, but **does not remove `www`**. To group `www.example.com` into `example.com`, register `example.com` and use that value in `data-hostname`. Register subdomains separately when you want separate reporting. These terminal examples use Bash, curl, and jq: ```bash EA_ORIGIN='https://clicktag.io' EA_HOSTNAME='example.com' : "${TT_TOKEN:?Copy an access token from the authentication guide first}" curl --fail-with-body --silent --show-error --retry 2 --max-time 30 \ "$EA_ORIGIN/api/sites" \ --header "Authorization: Bearer $TT_TOKEN" ``` Look for an exact match in the returned `sites` array. This list is owner-scoped; a publicly readable dashboard is not proof of ownership. If the hostname is absent, create it: ```bash curl --fail-with-body --silent --show-error --max-time 30 \ "$EA_ORIGIN/api/sites" \ --header "Authorization: Bearer $TT_TOKEN" \ --header 'Content-Type: application/json' \ --data "$(jq -n --arg hostname "$EA_HOSTNAME" '{hostname: $hostname}')" ``` A successful create returns `201` with `{"hostname":"example.com","verified_at":null,"verify_token":"tt-verify-...","kind":"web"}`; a hostname you already own returns `200` with its existing registration. On `409`, re-read the owned list and proceed only if the hostname is now there. After an uncertain network failure, read before retrying the create. Resolve ownership conflicts rather than choosing a different hostname that your tag will never send. **Ownership must be verified before the site collects data or can be made public.** Automatic verification requires the actual script tag in the served homepage HTML with `data-verify` matching the site's `verify_token` and `data-hostname` matching the registration. Copy the site-specific snippet from settings, or retrieve `verify_token` with owner-authenticated `GET /api/sites/{hostname}`. The first beacon triggers the check and is retained on success; until then beacons are dropped, and a later beacon can retry a failed check after about 30 seconds. A generic tag is not ownership proof; existing verified sites do not need a snippet change. If the tag is injected client-side, publish `totallytics-verify={verify_token}` as a TXT record at `_totallytics-verify.{hostname}`, or serve the bare `verify_token` as the body of `https://{hostname}/.well-known/totallytics-verify.txt` with a `200` and no redirect, then call `POST /api/sites/{hostname}/verify`. `GET /api/sites/{hostname}/setup-check` (owner) reports tag presence and CSP blocks. If you cannot complete any of these from this environment, leave the site unverified and report verification as a pending manual step. See [Site management: verify ownership](https://clicktag.io/docs/sites#verify-ownership). Private site metadata and web-statistics reads return `404 unknown site` for missing or invalid ID tokens as well as unknown or non-owned hostnames; an owner's web-statistics reads return `403` until the site is verified. Hostname-scoped management routes require sign-in first (`401` without a valid token), then return `404 unknown site` for unknown or non-owned hostnames. A management key is checked first: an unknown or revoked key returns `401`, and a key without the needed scope returns `403`. See [Site management](https://clicktag.io/docs/sites) for limits and settings. Do not make a dashboard public to bypass an authentication problem. ## 2. Install once in the shared document Use the registered hostname and its public verification challenge in the tag. Replace `YOUR_SITE_VERIFY_TOKEN` with `verify_token`, never `TT_TOKEN`: ```html ``` Put it in the shared document or root layout, not a component that remounts on navigation. - **Static sites and Vite:** inspect every HTML entrypoint, including separate marketing pages. Update generators as well as generated output. - **React and SSR frameworks:** use the persistent document/root layout, such as Next.js `app/layout.tsx` or Remix `app/root.tsx`. Preserve attributes when using a framework script component. Avoid inserting the tag on every render or effect. - **Client-side routing:** the tag already tracks distinct paths on `pushState` and `popstate`. Hash routing needs `data-mode="hash"`. It does not directly listen to `replaceState`, and query-only changes are not new page paths. - **Manual pageviews:** `window.tt_pageview()` exists only with `data-auto-collect="false"`. Wait for load, then handle both initial and subsequent views yourself. Repeated consecutive calls for the same path are suppressed. See [Installation](https://clicktag.io/docs/installation). If you need a JavaScript-disabled fallback, put this optional pixel in the **server-rendered body**: ```html ``` Keep `loading="lazy"` before `src`, and keep the `style`. With JavaScript on, lazy loading stops the pixel from loading when a framework renders it, which would count the pageview twice. The fixed position lets script blockers that unwrap `