API reference

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.

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

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