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 --fail-with-body --silent --show-error --max-time 20 \
-H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \
https://analytics.bitgate.dev/api/sitesconst 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 --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"}'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 --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}"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 --fail-with-body --silent --show-error --max-time 20 \
https://analytics.bitgate.dev/api/sites/demo.bitgate.devconst 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());{
"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 --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}'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 --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}"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.