# 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](/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 <token>`. Copy a token from [Authentication](/docs/authentication), then set `TT_TOKEN` in your terminal. Site API keys (`tt_...`) only authenticate [API analytics ingest](/docs/api-requests#send-request-metrics) and cannot manage sites. Management keys (`tt_mk_...`) can; see [Management keys](/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](/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`](/docs/api-requests#create-a-key) and the owner's Firebase ID token. [API analytics](/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](/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](/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 <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.
