Clicktag docs
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.
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 <token>. Copy a token from Authentication, then set TT_TOKEN in your terminal. Site API keys (tt_...) only authenticate API analytics ingest and cannot manage sites. Management keys (tt_mk_...) can; see 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 --fail-with-body --silent --show-error --max-time 20 \
-H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \
https://clicktag.io/api/sitesconst 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 |
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.
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). 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.
Replace your-domain.example with your hostname. The write examples on this page are templates; the public demo is for reads only.
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"}'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.
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 --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}"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 --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"}'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 and the owner's Firebase ID token. 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, 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 allurl/status: the final URL (after allowed redirects within the hostname and itswww.twin) and HTTP statusverified_at: the site's verification timestamp, ornullwhile unverifiedtag_host: which Clicktag host serves a script tag that reports to this site, ornullwhen there is nonetag_reports_to: when the HTML has Clicktag tags but none report to this site, where the first one reports to (itsdata-hostname, or the page's own hostname without one); otherwisenulltag_verifies: while the site is unverified, whether a tag that reports to it carries this site'sdata-verifytoken, which auto-verification needs;nullonce the site is verified or when no tag reports to itcsp: whether yourContent-Security-Policyallows the script, the pageview pixel (img-src), and engagement beacons (connect-src), eachallowed/blocked/unknownwith 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 --fail-with-body --silent --show-error --max-time 20 \
https://clicktag.io/api/sites/demo.bitgate.devconst 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());{
"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. 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 --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}'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 --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}"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 |
| 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 <n>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.