# Sites API

A registered hostname is the unit of collection and reporting in Totallytics. Use the sites API to list your websites, register a hostname, or change its display name and visibility.

Base URL: `https://analytics.bitgate.dev`. [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. There are no product API 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                              |

Responses are JSON with `Cache-Control: no-store`. Call the product API from a server or the Totallytics 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, ordered 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://analytics.bitgate.dev/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://analytics.bitgate.dev/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                            |

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 and lowercases it. 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. The first label cannot begin or end with a hyphen. Do not include a scheme, port or path.

Registration creates a private, **unverified** site with an empty display name. Hostnames are unique across accounts. The account limit is 50 sites.

A new site does not collect data until you verify ownership of the hostname (see [Verify ownership](#verify-ownership)); the collector discards traffic for unverified 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://analytics.bitgate.dev/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://analytics.bitgate.dev/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-…" }`. Keep the `verify_token`; you need it to prove ownership next. No other body fields are used.

## Verify ownership

`POST /api/sites/{hostname}/verify`

The collector only stores data for **verified** sites, and only a verified site can be made public. Prove you control the hostname with either of two methods; one passing is enough.

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

```curl
curl --fail-with-body --silent --show-error --max-time 30 \
  -X POST https://analytics.bitgate.dev/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://analytics.bitgate.dev/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 neither method is detected, the response is `422` with per-method `dns` and `file` details; publish the proof and retry. An already-verified site returns `200` with `already: true`.

## 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://analytics.bitgate.dev/api/sites/demo.bitgate.dev
```

```typescript
const response = await fetch(
  "https://analytics.bitgate.dev/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"
}
```

Unlike the account-wide list, this response does not include `owner_uid`.

## Update a site

`PATCH /api/sites/{hostname}`

| Body field     | Type    | Behavior                                                                                                               |
| -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- |
| `display_name` | string  | Optional. Truncated to 128 characters; an empty string clears it. Missing or `null` retains the current value.         |
| `is_public`    | boolean | Optional. `true` enables anonymous metadata and stats reads. Missing or non-boolean values retain the current setting. |

There is no rename or ownership-transfer field. Extra fields are ignored. An empty object or unreadable JSON is treated as no change; use the documented field types rather than relying on permissive parsing.

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -X PATCH https://analytics.bitgate.dev/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://analytics.bitgate.dev/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` and `is_public`. Setting `is_public` to `true` on an unverified site fails with `400`; verify ownership first. Making a site public exposes its metadata and every supported stats report, not only a share-page link.

## Delete a site

`DELETE /api/sites/{hostname}`

Deletes the registration and that site's import-job records, not historical analytics. 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://analytics.bitgate.dev/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://analytics.bitgate.dev/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.

## Errors and caching

Errors use `{ "error": "message" }`. To avoid revealing which hostnames are registered, all unauthorized private reads and mutations return a uniform `404 unknown site` rather than distinguishing "does not exist" from "belongs to someone else".

| Status | Message               | Meaning                                                                                 |
| ------ | --------------------- | --------------------------------------------------------------------------------------- |
| 400    | `invalid hostname`    | Registration hostname is missing or fails validation                                    |
| 400    | `site limit reached`  | Account already has 50 registered sites                                                 |
| 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)                  |
| 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    | `site already exists` | Hostname is already registered                                                          |
| 422    | `verification not found yet` | Neither verification method was detected; see the `dns`/`file` details |
| 500    | `internal error`      | Request could not be completed; this can also result from incorrectly typed body fields |

Site lookups are cached at the edge: a known registration for up to two minutes, a missing or unverified one for up to ten. 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. Anonymous reads of a public site's overview and breakdown are additionally edge-cached for one minute.
