# Totallytics > Website analytics at https://totallytics.com. Register a site, install the tracking script, and query statistics. Private reports, site management, and historical imports require a signed-in user's Firebase ID token. Public-site metadata/statistics and collection do not. There are no static Totallytics API keys. Import history only when requested, following the import and migration guides. Collector HTTP 200 does not prove persistence. Read the setup guide before changing a site. ## Documentation - [Quickstart](https://totallytics.com/docs/quickstart.md): Register your website, add one script, and verify your first pageview. - [Installation](https://totallytics.com/docs/installation.md): HTML, React, Next.js, single-page apps, script settings, and CSP. - [Custom events](https://totallytics.com/docs/events.md): Track the actions that matter, in the browser or on your server. - [Authentication](https://totallytics.com/docs/authentication.md): Get an account token and understand which requests need one. - [Sites](https://totallytics.com/docs/sites.md): Register, list, update, and remove your websites. - [Statistics](https://totallytics.com/docs/stats.md): Query overviews, time series, live visitors, and breakdowns. - [Historical imports](https://totallytics.com/docs/imports.md): Discover sources, start background imports, and inspect their progress. - [Collector](https://totallytics.com/docs/collector.md): Send pageviews, events, and engagement data over HTTP. - [AI agent setup](https://totallytics.com/docs/agents.md): Give your coding agent a complete, verifiable setup plan. - [Migrate from Simple Analytics](https://totallytics.com/docs/migrate-simple-analytics.md): Switch your tracking script and plan your historical data import. - [Troubleshooting](https://totallytics.com/docs/troubleshooting.md): Find missing pageviews, fix authentication, and interpret errors. ## Machine-readable reference - [Complete documentation](https://totallytics.com/llms-full.txt) - [OpenAPI 3.1](https://totallytics.com/openapi.json) --- Source: https://totallytics.com/docs # Quickstart Register your website, add one script, and check your first pageview. That’s the whole setup. ## 1. Register your site Register your hostname before sending traffic. The collector normally drops traffic for unregistered hostnames without returning an error. 1. Sign in at [/login](https://analytics.bitgate.dev/login). For a new account, choose Google. 2. Navigate to [/app](https://analytics.bitgate.dev/app) and click **Add site**. 3. Enter your production hostname, such as `example.com`. Do not include URL schemes (`https://`), paths, or port numbers. Each account can register up to 50 sites. All sites are private by default. You can also register sites programmatically using `POST /api/sites`. See the [Sites API Reference](https://totallytics.com/docs/sites) for request schemas. ## 2. Add the tracking script Add this single script tag to your site HTML. Place it in your shared layout, document head, or before the closing body tag: ```html ``` To capture visits from users with JavaScript disabled, add an optional noscript pixel inside the HTML ``: ```html ``` Always specify `data-hostname` to avoid domain detection mismatches. Subdomains and `www` prefixes are separate hostnames and are not combined automatically. Set `data-hostname` to the exact value you registered. ## 3. Verify incoming traffic Open your site in a standard desktop or mobile web browser to verify the installation: 1. Open your browser Developer Tools and select the **Network** tab. 2. Filter requests by `bitgate.dev`. 3. Verify that `latest.js` loads with HTTP 200. 4. Find the request to `simple.gif`. Check that its query includes `hostname=example.com` and `type=pageview`, and that the response is HTTP 200 with content type `image/gif`. 5. Navigate away or close the tab. The tracker may send an `/append` engagement beacon; it is not a second pageview. A collector HTTP 200 is not proof that a row was saved: writes are asynchronous, and requests can be ignored. Check the exact page in [/app](https://analytics.bitgate.dev/app), or query your site’s [pages breakdown](https://totallytics.com/docs/stats). Newly registered sites can take about a minute to reach collector instances that cached an earlier lookup. When testing, keep these checks in mind: - Use a real browser. Headless browsers with `navigator.webdriver` enabled are marked as automated traffic; bot-marked rows do not count toward normal visitor and pageview totals. - Check browser extensions. Ad blockers and privacy tools can block analytics endpoints. - Check Do Not Track. The script honors `navigator.doNotTrack === "1"` by default and will not record visits. ## 4. Explore the API You can query public stats directly without an account using our public demo domain: ```bash curl --fail-with-body -sS --max-time 30 \ 'https://analytics.bitgate.dev/api/sites/demo.bitgate.dev/overview?tz=UTC' ``` This endpoint returns JSON containing `totals`, `previous`, `series`, `live`, and `granularity` fields. Note that demo traffic is synthetically generated. Public-site metadata and statistics allow unauthenticated reads. Private reports require the site owner’s token in the Authorization header. Import jobs remain owner-only even for public sites. Read the [Stats API Reference](https://totallytics.com/docs/stats) for detailed parameter options and the [Authentication Guide](https://totallytics.com/docs/authentication) for bearer token usage. --- Source: https://totallytics.com/docs/installation # Installation Add Totallytics to your application layout, single-page app, or modern frontend framework. ## Script placement Load `latest.js` once in your root document. Do not mount duplicate script tags across child views. ### Plain HTML Place the script in your shared template header or footer: ```html My App
``` ### React and Vite Add the script directly inside `index.html` at the project root: ```html Vite App
``` ### Next.js (App Router) Place a raw ` ``` ## Manual pageview tracking If you prefer manual control over pageviews, disable automatic collection with `data-auto-collect="false"`. When disabled, trigger pageviews using `window.sa_pageview(path, metadata)`. Calls recording an identical consecutive path are automatically suppressed. Verify that the script has loaded before calling `window.sa_pageview`: ```html ``` ## Script configuration options Configure behavior by setting data attributes on the ` ``` For most settings, `window.sa_settings` overrides the equivalent attribute; `data-auto-collect="false"` still disables automatic pageviews. Avoid conflicting configurations and set them before `latest.js` executes. Totallytics does not host the optional [automatic-events companion scripts](https://totallytics.com/docs/events#automated-event-scripts). ## Privacy and Do Not Track By default, `navigator.doNotTrack === "1"` stops normal collection after the script loads. Keep this default to respect the visitor’s setting. ## Content Security Policy (CSP) Totallytics uses `new Image()` for pageview beacons and `navigator.sendBeacon` for `/append` duration tracking. If your site serves a Content Security Policy header, merge `https://analytics.bitgate.dev` into your existing directives. Do not overwrite your wider security rules: ```text script-src 'self' https://analytics.bitgate.dev; img-src 'self' https://analytics.bitgate.dev; connect-src 'self' https://analytics.bitgate.dev; ``` If your policy defines `script-src-elem`, include `https://analytics.bitgate.dev` there as well. Inline settings, event queues, and `onload` examples also need your existing inline-code policy; prefer external app code or your framework’s nonce/hash mechanism rather than adding `unsafe-inline`. ## Testing on localhost By default, `latest.js` extracts `location.host` when `data-hostname` is omitted. On local development environments, this results in values like `localhost:3000`. The script can still send on localhost; it is not a reliable development-mode exclusion. An unregistered local hostname is normally ignored by the collector, while an explicit production `data-hostname` can record local test traffic against your live site. To verify integrations, test on an explicit staging or production domain that matches a registered hostname. Do not direct localhost test traffic to your production hostname. Next, explore [Custom Events](https://totallytics.com/docs/events) or review the [Troubleshooting Guide](https://totallytics.com/docs/troubleshooting). --- Source: https://totallytics.com/docs/events # Custom Events Track conversions, button clicks, and custom application workflows using browser calls or backend requests. ## Browser event tracking Record events in the browser using `window.sa_event`. To record events before `latest.js` finishes loading, declare the asynchronous buffer queue early in your document: ```html ``` Always use standard function syntax rather than an arrow function so that `arguments` is captured correctly. ### Triggering events from UI Call `window.sa_event(name, metadata?, callback?)` inside event handlers: ```html ``` ### TypeScript definitions If you use TypeScript, augment the global `Window` interface: ```typescript declare global { interface Window { sa_event?: ( name: string, metadata?: Record, callback?: () => void, ) => void; sa_pageview?: ( path?: string, metadata?: Record, ) => void; } } export {}; ``` ## Naming conventions and normalization Use short, descriptive event names in lowercase snake_case, such as `signup_completed` or `pricing_opened`. Incoming event names undergo automated normalization: - The collector truncates names exceeding 256 characters before normalization. - Non-alphanumeric character sequences are converted to underscores. - Leading and trailing underscores are stripped. ## Event metadata rules Event metadata must be a plain JavaScript object: - The collector stores at most 4,096 characters of serialized metadata JSON. - Payloads exceeding this limit are truncated, which may result in non-parseable JSON. - Send compact, non-sensitive strings, numbers, or booleans. Totallytics aggregates event counts and unique visitor totals by event name. The API does not provide custom metadata filtering, funnel analysis, or custom goal query endpoints. Query aggregated event breakdown data using the API: ```bash curl --fail-with-body -sS --max-time 30 \ 'https://analytics.bitgate.dev/api/sites/demo.bitgate.dev/breakdown?dim=events&limit=10' ``` ## Event callbacks The optional callback runs on image load/error and can also run immediately after a local validation failure, without a request: ```javascript window.sa_event("lead_form_submitted", { source: "nav" }, function () { console.log("Event dispatch finished"); }); ``` The callback is not a success signal; it can run even when no request was sent. It does not guarantee persistence or delivery to permanent storage. Do not gate mission-critical logic on this callback. ## Automated event scripts `latest.js` does not record link clicks, file downloads, or form submissions automatically. Companion scripts like `/auto.js` or `/auto-events.js` are not supported. If migrating from older scripts, attach explicit `window.sa_event` triggers. ## Backend server-side events Send events directly from backend services via `POST /events` without loading client tracking scripts: Use the real incoming visitor user-agent, not a fabricated browser string. This example runs in a backend with `fetch` support: ```typescript async function recordSignup(hostname: string, visitorUserAgent: string) { if (!hostname || !visitorUserAgent) { throw new Error( "A registered hostname and the real visitor user-agent are required", ); } const response = await fetch("https://analytics.bitgate.dev/events", { method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(10_000), body: JSON.stringify({ hostname, type: "event", event: "signup_completed", path: "/signup", metadata: { plan: "starter" }, ua: visitorUserAgent, }), }); const result = await response.text(); if (!response.ok) { throw new Error(`Event collector ${response.status}: ${result}`); } return result; } ``` Server-side requirements and behaviors: - Send a JSON-encoded object. Use `Content-Type: application/json` for clarity, although the handler parses JSON regardless of that header. Form-encoded bodies are not accepted. - Payload must be a single JSON object. Batch requests are not supported. - Always set `"type": "event"`. Omitting this field records the request as a standard pageview. - Returns HTTP 200 with plain text `ok` on success, or HTTP 400 plain text on malformed input. - Pass the visitor user-agent string via the `ua` field. - Country and geolocation are computed from the incoming HTTP connection IP. Server-side dispatches reflect the geographic location of your server, not the end user. Client IP overrides are not accepted. - Timestamps are assigned on server arrival. You cannot pass custom timestamps or backdate events. - There is no accepted user-ID or client-IP override. Visitor hashes incorporate the UTC day, site, connection IP, user-agent, and a deployment salt, so a shared server does not recreate end-user visitor identity. Bot-marked rows are excluded from normal metrics; bot classification includes the effective user-agent and the `bot` payload field. - There is no idempotency key. Do not automatically retry an event POST after an ambiguous timeout: a retry can record the event twice. Review the [Collector Specification](https://totallytics.com/docs/collector) for low-level protocol details. --- Source: https://totallytics.com/docs/authentication # Authentication Totallytics uses Firebase ID tokens to authenticate requests to private reports and administrative APIs. ## Authentication model Administrative endpoints and private site statistics require a valid Firebase ID token passed in the standard Authorization header: ```text Authorization: Bearer ``` Key credential rules: - Totallytics does not use static API keys or server-to-server service account JSON credentials. - Simple Analytics credentials cannot authenticate Totallytics API calls. They are only used as source configuration for [historical imports](https://totallytics.com/docs/imports). - The client Firebase configuration API key is not an access token and will be rejected. ## Obtaining an access token Access tokens are short-lived JWTs (typically valid for approximately one hour). To acquire your token for development or testing: 1. Sign in to your account at [https://analytics.bitgate.dev/login](https://analytics.bitgate.dev/login). 2. Navigate back to this page ([/docs/authentication](https://analytics.bitgate.dev/docs/authentication)). 3. Click **Copy access token** in the interactive panel. This copies your active Firebase ID token directly to your clipboard, refreshing credentials if necessary. ## Using tokens in shell scripts To prevent tokens from appearing in shell history or committed code, read the token into an environment variable using silent terminal input: ```bash read -rsp "Totallytics ID Token: " TT_TOKEN && export TT_TOKEN ``` Pass the variable in the Authorization header of your API requests: ```bash curl -sS --fail-with-body \ -H "Authorization: Bearer $TT_TOKEN" \ -H "Accept: application/json" \ https://analytics.bitgate.dev/api/sites ``` Keep your token secure. Never include ID tokens in frontend client bundles, public repositories, or tracking script attributes. ## Endpoint authorization rules Different Totallytics endpoints enforce distinct access rules: - Public collection (`latest.js`, `simple.gif`, `POST /events`): No token required. Collector CORS allows any origin; the payload’s registered hostname is checked separately. - Public site reads (`GET /api/sites/`, `/api/sites//overview`, and `/api/sites//breakdown`): No token required if the site owner has toggled site visibility to public in dashboard settings. - Private site statistics: Requests without a token or with an invalid token return HTTP 401. Requests from an authenticated user who does not own the site return HTTP 403. - Import source discovery requires sign-in. Site import jobs and their controls require the site owner, even if its dashboard is public. - Site management (`GET /api/sites`, `POST /api/sites`, `PATCH /api/sites/`, `DELETE /api/sites/`): Requires a valid ID token belonging to the site owner. ### Error responses Authentication errors return standard JSON payloads accompanied by a `Cache-Control: no-store` header: ```json { "error": "sign in required" } ``` Expected status codes: - `401 Unauthorized`: Token is missing, expired, or malformed. - `403 Forbidden`: Token is valid, but the account lacks permission for this site. - `404 Not Found`: The specified hostname does not exist. ## Token expiration and retries Tokens expire after about one hour. There is no long-lived automation credential or product token-refresh endpoint. For unattended jobs, provide an authenticated Firebase user session and handle token refresh before scheduling requests. For an existing signed-in session: 1. If a request returns HTTP 401, get a fresh ID token from your signed-in Firebase client session (`user.getIdToken(true)`) or use **Copy access token** again. The Firebase Admin SDK is not a refresh mechanism for a user’s ID token. 2. Re-run safe, idempotent requests (such as `GET` queries) using the new token. 3. Do not retry state-mutating requests (`POST`, `DELETE`) blindly without checking site state first. ## Cross-Origin Resource Sharing (CORS) Actual administrative and statistics responses (`https://analytics.bitgate.dev/api/*`) do not include CORS headers. The global OPTIONS handler does answer preflights, but that does not make the API cross-origin readable. Browser applications cannot query the API across origins, even for public sites. Make all API calls from backend services, serverless functions, or from within the Totallytics web origin. Review the [Stats API Reference](https://totallytics.com/docs/stats) for querying overview charts and breakdowns. --- Source: https://totallytics.com/docs/sites # 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](https://totallytics.com/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 `. Copy a token from [Authentication](https://totallytics.com/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](https://totallytics.com/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. --- Source: https://totallytics.com/docs/stats # Stats API The stats API delivers traffic summaries, breakdowns and live activity for registered hostnames. Query the public demo without a token, or send an owner's Firebase ID token for a private site. Base URL: `https://analytics.bitgate.dev`. [OpenAPI document](https://totallytics.com/openapi.json) | [Authentication](https://totallytics.com/docs/authentication) 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`. ## Read a public report `GET /api/sites/{hostname}/overview` These examples only read `demo.bitgate.dev`. The curl example uses the default trailing 30 days; the TypeScript example selects the last 24 complete UTC hours. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ 'https://analytics.bitgate.dev/api/sites/demo.bitgate.dev/overview?tz=UTC' ``` ```typescript const to = Math.floor(Date.now() / 3_600_000) * 3_600; const query = new URLSearchParams({ from: String(to - 86_400), to: String(to), tz: "UTC", }); const response = await fetch( `https://analytics.bitgate.dev/api/sites/demo.bitgate.dev/overview?${query}`, { signal: AbortSignal.timeout(20_000) }, ); if (!response.ok) { throw new Error(`Overview ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Private reads add `Authorization: Bearer `. Product API responses have no cross-origin CORS headers: use a server-side client or the Totallytics origin, even for public reports. ## Date ranges The overview and breakdown endpoints share the same range parser. | Parameter | Type | Default and bounds | | --------- | ------ | ----------------------------------------------------------------------------- | | `from` | number | Unix seconds, inclusive; rounded down. Defaults to `to - 30 * 86400`. | | `to` | number | Unix seconds, exclusive; rounded up. Defaults to current Unix seconds. | | `tz` | string | Overview only. Defaults to `UTC`; affects series grouping, not the UTC range. | The rounded range must satisfy `from < to` and span no more than 400 days. Send seconds, not JavaScript milliseconds. Missing, empty, zero or non-numeric `from`/`to` values select their defaults rather than returning a validation error. Non-finite values or an invalid range return `400 invalid range`. ### Timezone handling Use `UTC` or a recognized zone such as `Europe/Amsterdam`. The parser accepts at most two slash-separated components, each 1–32 characters of letters, digits, `_`, `+` or `-`. Other syntax silently becomes `UTC`; this includes multi-slash names such as `America/Argentina/Buenos_Aires`. A syntactically accepted but unknown zone can return `500 internal error`. Daily series timestamps are generated by converting a grouped calendar date to Unix seconds. They are not guaranteed to represent midnight in the requested timezone. Use `tz=UTC` for unambiguous date labels; the account summary returns explicit `YYYY-MM-DD` day strings. ### Hour boundaries Overview pageviews, visitors and series filter complete hourly aggregates by their hour's start. A range starting at 10:30 excludes the 10:00 bucket; an end at 11:30 includes the whole 11:00 bucket. Use UTC hour-aligned bounds when comparing these numbers with breakdowns, which filter individual timestamps. Duration and scroll averages use individual append timestamps. The previous period applies the same rules to the immediately preceding interval of equal length. ## Overview response | Field | Type | Meaning | | -------------------------------------------------- | --------------- | --------------------------------------------------------------------------------- | | `totals` | object | Metrics for the requested range | | `previous` | object | Same metrics for `[from - (to - from), from)` | | `totals.pageviews`, `previous.pageviews` | number | Non-bot pageview count from hourly aggregates | | `totals.visitors`, `previous.visitors` | number | Approximate distinct pageview visitor identifiers across the range | | `totals.avg_duration_s`, `previous.avg_duration_s` | number | Rounded mean duration in seconds across non-bot append rows; zero if none | | `totals.avg_scroll`, `previous.avg_scroll` | number | Rounded mean scroll percentage across those append rows; zero if none | | `series` | array | Time-ordered buckets; missing buckets are not zero-filled | | `series[].t` | number | Bucket timestamp in Unix seconds | | `series[].pageviews` | number | Non-bot pageviews in the bucket | | `series[].visitors` | number | Approximate distinct pageview visitor identifiers in the bucket | | `live` | number | Exact distinct visitor identifiers with non-bot activity in the last five minutes | | `granularity` | `hour` or `day` | `hour` for ranges up to four days; `day` for longer ranges | Example response: ```json { "totals": { "pageviews": 3, "visitors": 2, "avg_duration_s": 42, "avg_scroll": 75 }, "previous": { "pageviews": 2, "visitors": 2, "avg_duration_s": 30, "avg_scroll": 50 }, "series": [ { "t": 1789516800, "pageviews": 1, "visitors": 1 }, { "t": 1789520400, "pageviews": 2, "visitors": 2 } ], "live": 0, "granularity": "hour" } ``` ## What the counts mean - Visitor identifiers depend on the UTC day, site, request IP and user agent. They rotate daily, so a person returning tomorrow is not deduplicated across days. The supplied collector `unique` flag does not determine these visitor counts. - Overview visitors use an approximate distinct aggregate. Breakdown visitors and `live` use exact distinct identifiers. These are identifiers, not a count of identified people or sessions. - `live` is independent of the selected date range and includes pageview, event, append and error activity. Event-only visitors can appear live without increasing pageview visitors. - Append averages are per append row, not per pageview or session. Appends are not joined back to pageviews for these calculations; repeated appends and zero-valued rows contribute to the mean in their own ingestion-time range. - Series may be empty, omit quiet intervals, or contain a zero-pageview bucket created by append activity. Zero-fill in your client only if your chart needs it. Do not add bucket visitor counts to obtain a deduplicated range total. ## Breakdowns `GET /api/sites/{hostname}/breakdown` | Parameter | Type | Default and bounds | | ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `dim` | string | Required; one of the ten dimensions below | | `from`, `to` | number | Same UTC range rules as overview | | `limit` | integer | Default `10`; numeric values clamp to `1`–`100`. Zero or non-numeric input uses `10`. Send integers: fractional values are not rounded and can fail. | `tz` has no effect on this endpoint. Rows are ordered by count descending. There is no cursor, offset, total-row count or metadata filter. | Dimension | Groups by | `value` counts | | --------------- | ---------------------- | -------------- | | `pages` | Page path | Pageviews | | `referrers` | Referrer hostname | Pageviews | | `countries` | Country | Pageviews | | `devices` | Parsed device category | Pageviews | | `browsers` | Parsed browser name | Pageviews | | `os` | Operating system name | Pageviews | | `utm_sources` | `utm_source` | Pageviews | | `utm_mediums` | `utm_medium` | Pageviews | | `utm_campaigns` | `utm_campaign` | Pageviews | | `events` | Event name | Event rows | All breakdowns exclude bot rows. Blank values are omitted except for `pages` and `referrers`, where an empty value is named `Direct / none`. Event metadata is stored by the collector but cannot be retrieved, grouped or filtered through this API. `utm_term` and `utm_content` are not breakdown dimensions. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ 'https://analytics.bitgate.dev/api/sites/demo.bitgate.dev/breakdown?dim=pages&limit=10' ``` ```typescript const query = new URLSearchParams({ dim: "pages", limit: "10" }); const response = await fetch( `https://analytics.bitgate.dev/api/sites/demo.bitgate.dev/breakdown?${query}`, { signal: AbortSignal.timeout(20_000) }, ); if (!response.ok) { throw new Error(`Breakdown ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Success is `200` with `{ "rows": [...] }`. Each row has `name` (string), `value` (number of matching rows) and `visitors` (exact distinct visitor identifiers within that group). An empty report returns `{ "rows": [] }`. A visitor can appear in several groups; group visitors must not be summed as a unique total. ## All-sites summary `GET /api/sites/summary?tz=UTC` Requires a Firebase ID token even when some owned sites are public. Returns all sites owned by that account, ordered by creation time, with a rolling window beginning 31 days ago and an independent five-minute live count. Only `tz` is read; `from`, `to` and `limit` do not customize this endpoint. ```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/summary?tz=UTC' ``` ```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/summary?tz=UTC", { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Summary ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` | Field | Type | Meaning | | -------------------------- | ------ | ------------------------------------------------------------- | | `sites` | array | All owned sites; empty when none are registered | | `sites[].hostname` | string | Registered hostname | | `sites[].live` | number | Same five-minute live definition as overview | | `sites[].days` | array | Available days, ascending; not zero-filled | | `sites[].days[].day` | string | `YYYY-MM-DD` calendar date in `tz` | | `sites[].days[].pageviews` | number | Non-bot pageview count from hourly aggregates | | `sites[].days[].visitors` | number | Approximate distinct pageview visitor identifiers for the day | Sites without activity remain present with `days: []` and `live: 0`. The rolling cutoff can give a partial first day; this is not 31 complete calendar days. The same timezone parser and hourly-boundary caveats apply. ## Errors Errors are JSON: `{ "error": "message" }`. Successful JSON responses and handled errors carry `Cache-Control: no-store`. | Status | Message | Meaning | | ------ | ------------------- | ------------------------------------------------------------------------ | | 400 | `invalid range` | Reversed, empty, non-finite or over-400-day rounded range | | 400 | `unknown dimension` | Missing or unsupported `dim` | | 401 | `sign in required` | Private report or account summary needs a valid token | | 403 | `not your site` | Valid token belongs to someone other than the private site's owner | | 404 | `unknown site` | Hostname is not registered | | 500 | `internal error` | Query failed; also possible with an unknown timezone or fractional limit | Site access is checked before range or dimension validation. Public reports ignore a missing or invalid token because public access is sufficient. [Site lookup caching](https://totallytics.com/docs/sites#errors-and-caching) can delay visibility changes. --- Source: https://totallytics.com/docs/imports # Imports API The Imports API manages background migrations of historical analytics into an Totallytics site. Jobs run asynchronously in monthly chunks. Base URL: `https://analytics.bitgate.dev`. [Download OpenAPI](https://totallytics.com/openapi.json) or [read this page as Markdown](https://totallytics.com/docs/imports.md). ## Access All import endpoints require a Firebase ID token in `Authorization: Bearer `. Sign in, open [Authentication](https://totallytics.com/docs/authentication), and use **Copy access token**. Totallytics has no static product API key; SimpleAnalytics credentials are source configuration, not Totallytics authentication. `GET /api/import-sources` accepts any signed-in account. Every site-specific import endpoint requires the site's owner, **even for public sites**. Authentication is checked before the site lookup. | Method | Route | Result | | ------ | ---------------------------------------------- | ------------------------------------------ | | GET | `/api/import-sources` | Supported sources and configuration fields | | POST | `/api/sites/{hostname}/imports` | Validate, create and queue a job | | GET | `/api/sites/{hostname}/imports` | Latest 50 jobs for the site | | GET | `/api/sites/{hostname}/imports/{jobId}` | One job's current state | | POST | `/api/sites/{hostname}/imports/{jobId}/cancel` | Cancel a queued or running job | | POST | `/api/sites/{hostname}/imports/{jobId}/retry` | Re-queue a failed or canceled job | Responses are JSON with `Cache-Control: no-store`. Product API responses have no cross-origin CORS headers; call from a server or the Totallytics origin. Use the normalized destination hostname returned by the [Sites API](https://totallytics.com/docs/sites); path lookups do not lowercase it. TypeScript examples run server-side on Node 20+: save a snippet as `example.mts`, set its environment variables, then run `npx tsx example.mts`. No example automatically retries a POST. ## Available sources `GET /api/import-sources` returns `{ "sources": [...] }`. Each source has `id`, `label`, `description` and `configFields`. A configuration field has `key`, `label`, `type` (`text`, `password` or `date`), `required`, and optional `placeholder` and `help` strings. The current source is `simpleanalytics`, labeled **SimpleAnalytics**: | Configuration key | Type | Required | Meaning | | ----------------- | -------- | -------- | ----------------------------------------------------------------------- | | `user_id` | text | Yes | User ID from SimpleAnalytics dashboard → Account → API | | `api_key` | password | Yes | SimpleAnalytics API key | | `source_hostname` | text | Yes | Hostname registered in SimpleAnalytics; may differ from the destination | ## Start an import `POST /api/sites/{hostname}/imports` requires `source`, `config`, `start` and `end`. Set `source` to `simpleanalytics` and supply all three configuration strings above. Strings are trimmed and truncated to 512 characters; unknown configuration keys are ignored. `source_hostname` is lowercased and must be a hostname containing a dot, without a scheme, port or path. Dates are inclusive UTC dates in `YYYY-MM-DD` format. `start` must be on or after `2010-01-01`, `start <= end`, and the difference `end - start` cannot exceed 1,826 days. There is no future-end cutoff. **The planner rounds `start` down to the first day of its month.** A request for `2026-08-15` through `2026-08-20` plans exports from August 1 through August 20. Use a month-boundary start to avoid including earlier days. The returned `range_start` still reflects the requested date. Each month has two chunks: pageviews, then events; the final month ends at the requested `end`. Before queueing, creation tests the source credentials by requesting a recent pageview export. This can take time. **`201` means queued, not import finished.** Check job progress and actual reports before considering the migration complete. Read credentials without putting them in shell history. Export `EA_HOSTNAME` for your registered destination, `SA_HOSTNAME` for the source, and `EA_IMPORT_START` / `EA_IMPORT_END` for your chosen dates. These examples create a real job when used with valid credentials. ```bash read -rsp "Totallytics access token: " TT_TOKEN; printf '\n' read -rsp "SimpleAnalytics user ID: " SA_USER_ID; printf '\n' read -rsp "SimpleAnalytics API key: " SA_API_KEY; printf '\n' export TT_TOKEN SA_USER_ID SA_API_KEY ``` The curl example requires Bash and `jq`; `jq --arg` safely encodes values, including quotes in credentials. ```curl set -o pipefail jq -n \ --arg user_id "${SA_USER_ID:?Set SA_USER_ID}" \ --arg api_key "${SA_API_KEY:?Set SA_API_KEY}" \ --arg source_hostname "${SA_HOSTNAME:?Set SA_HOSTNAME}" \ --arg start "${EA_IMPORT_START:?Set EA_IMPORT_START}" \ --arg end "${EA_IMPORT_END:?Set EA_IMPORT_END}" \ '{source:"simpleanalytics",config:{user_id:$user_id,api_key:$api_key,source_hostname:$source_hostname},start:$start,end:$end}' | curl --fail-with-body --silent --show-error --max-time 300 \ -X POST "https://analytics.bitgate.dev/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports" \ -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \ -H 'Content-Type: application/json' --data-binary @- ``` ```typescript const { TT_TOKEN, EA_HOSTNAME, SA_USER_ID, SA_API_KEY, SA_HOSTNAME, EA_IMPORT_START, EA_IMPORT_END, } = process.env; if ( !TT_TOKEN || !EA_HOSTNAME || !SA_USER_ID || !SA_API_KEY || !SA_HOSTNAME || !EA_IMPORT_START || !EA_IMPORT_END ) { throw new Error( "Set the token, destination, source credentials, hostname and dates", ); } const response = await fetch( `https://analytics.bitgate.dev/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports`, { method: "POST", headers: { Authorization: `Bearer ${TT_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ source: "simpleanalytics", config: { user_id: SA_USER_ID, api_key: SA_API_KEY, source_hostname: SA_HOSTNAME, }, start: EA_IMPORT_START, end: EA_IMPORT_END, }), signal: AbortSignal.timeout(300_000), }, ); if (!response.ok) { throw new Error(`Create import ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` The response is the job object directly. Save its `id` as `EA_IMPORT_ID` to read progress. At most 20 new jobs per owner can be created in a rolling 24-hour window across all sites and statuses; the limit returns `429`. Only one `queued` or `running` job may exist per site; another creation returns `409`. Retrying the same job is not a new creation. There is no idempotency key or cross-job deduplication ledger. Reimporting an overlapping period can duplicate records. After a timeout or ambiguous response, inspect the site's jobs before attempting another POST. ## Read progress `GET /api/sites/{hostname}/imports` returns `{ "imports": [...] }`, ordered by `created_at` descending, with at most 50 entries. There are no pagination, cursor or offset parameters. `GET /api/sites/{hostname}/imports/{jobId}` returns the job object directly. To list jobs instead, remove `/{jobId}` from the request below. Source discovery uses the same bearer header with `/api/import-sources`. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \ "https://analytics.bitgate.dev/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}" ``` ```typescript const { TT_TOKEN, EA_HOSTNAME, EA_IMPORT_ID } = process.env; if (!TT_TOKEN || !EA_HOSTNAME || !EA_IMPORT_ID) { throw new Error("Set TT_TOKEN, EA_HOSTNAME and EA_IMPORT_ID"); } const response = await fetch( `https://analytics.bitgate.dev/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports/${encodeURIComponent(EA_IMPORT_ID)}`, { headers: { Authorization: `Bearer ${TT_TOKEN}` }, signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Import ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` ## Job object Create, get, cancel and retry return this object. List entries use the same shape; `owner_uid` and `updated_at` are not returned. | Field | Type | Meaning | | --------------- | -------------- | ---------------------------------------------------------------------------------------- | | `id` | string | UUID job identifier | | `site` | string | Destination Totallytics hostname | | `source` | string | Source ID, currently `simpleanalytics` | | `source_label` | string | Source display label, currently `SimpleAnalytics` | | `status` | string | `queued`, `running`, `completed`, `failed` or `canceled` | | `range_start` | string | Requested inclusive start date, `YYYY-MM-DD` | | `range_end` | string | Requested inclusive end date, `YYYY-MM-DD` | | `chunks_total` | integer | Planned pageview and event chunks | | `chunks_done` | integer | Fully checkpointed chunks | | `rows_imported` | number | Checkpointed inserted records: pageviews, events and derived duration/scroll append rows | | `rows_skipped` | number | Checkpointed source records skipped for another hostname or an event without a name | | `error` | string or null | Latest worker error, up to 500 characters | | `config` | object | Non-password fields: `user_id` and `source_hostname`; never `api_key` | | `created_at` | string | ISO date-time of creation | | `finished_at` | string or null | ISO date-time of completion, failure or cancellation; normally null while active | Request and returned range dates use `YYYY-MM-DD`, for example `2026-08-01`. Progress updates only after a whole chunk finishes. Partial writes may not appear in counters, and retry duplicates are not reconciled: these counts are neither unique pageviews nor proof of exact or durable storage totals. A job may have an `error` while still `queued` or `running` as the worker retries. Historic visitor identities are approximated from day, user agent, country and source hostname; report visitor counts need not match SimpleAnalytics exactly. ## Cancel or retry Both actions accept a POST with no body and return `200` with the job object. | Action | Allowed state | Effect | | --------- | ---------------------- | ------------------------------------------------------------------------ | | `/cancel` | `queued` or `running` | Sets status to `canceled` | | `/retry` | `failed` or `canceled` | Sets status to `queued`, clears the error and resumes from `chunks_done` | The examples cancel a job. To retry an eligible job, replace `/cancel` with `/retry` after checking its state. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST \ -H "Authorization: Bearer ${TT_TOKEN:?Copy an access token}" \ "https://analytics.bitgate.dev/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/imports/${EA_IMPORT_ID:?Set EA_IMPORT_ID}/cancel" ``` ```typescript const { TT_TOKEN, EA_HOSTNAME, EA_IMPORT_ID } = process.env; if (!TT_TOKEN || !EA_HOSTNAME || !EA_IMPORT_ID) { throw new Error("Set TT_TOKEN, EA_HOSTNAME and EA_IMPORT_ID"); } const response = await fetch( `https://analytics.bitgate.dev/api/sites/${encodeURIComponent(EA_HOSTNAME)}/imports/${encodeURIComponent(EA_IMPORT_ID)}/cancel`, { method: "POST", headers: { Authorization: `Bearer ${TT_TOKEN}` }, signal: AbortSignal.timeout(20_000), }, ); if (!response.ok) { throw new Error(`Cancel import ${response.status}: ${await response.text()}`); } console.log(await response.json()); ``` Cancellation stops later queued work, not an in-flight export or insert, and does not roll back rows. A retry skips checkpointed chunks, but partial writes in the current chunk and ambiguous insert retries can duplicate records. Retrying is **not idempotent or exactly-once**. It does not accept replacement credentials or dates; completed jobs cannot be retried. Cancel and retry responses use the prior job snapshot with status overrides, so `finished_at` can be stale. Poll the GET endpoint for the persisted state. ## Errors and credentials Errors use `{ "error": "message" }`. | Status | Message or condition | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid date range`, `unknown import source`, missing configuration fields, `Source hostname is invalid`, or a provider validation error | | 401 | `sign in required`: missing, invalid or expired token | | 403 | `not your site`: authenticated caller is not the site owner | | 404 | `unknown site`, or `unknown import` when the job is absent or belongs to another site | | 409 | `an import is already running for this site`, `import is not active` for cancel, or `only failed or canceled imports can be retried` | | 429 | `too many imports today, try again tomorrow`: rolling 24-hour creation limit reached | | 500 | `internal error`; retry queue failure may leave the job `queued`, and retrying while another job is active can also produce this error | | 502 | `import queue unavailable, please try again`: creation could not enqueue and cancels the new job | Password configuration fields are never echoed in the job's `config`. Completion removes the stored `api_key`; failed and canceled jobs retain it for retry. The non-password fields remain visible. Error text may include upstream response snippets and is not generally secret-redacted. After an enqueue error, check job state before taking another action. Compare actual data with the [Stats API](https://totallytics.com/docs/stats); use the [SimpleAnalytics migration guide](https://totallytics.com/docs/migrate-simple-analytics) to plan the wider cutover. --- Source: https://totallytics.com/docs/collector # Collector reference Send pageviews and events to Totallytics with the browser tracker, an image beacon or a JSON request. Collector routes do not use Firebase tokens or API keys; [register the hostname](https://totallytics.com/docs/sites#register-a-hostname) before sending traffic. Base URL: `https://analytics.bitgate.dev`. [OpenAPI document](https://totallytics.com/openapi.json) | [Install the tracker](https://totallytics.com/docs/installation) 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`. ## Routes | Method | Route | Input | Success | | ------ | --------------- | -------------------------------------------------- | --------------- | | GET | `/latest.js` | Tracker configuration lives on the script tag | JavaScript | | GET | `/simple.gif` | Query parameters | 1×1 GIF | | GET | `/noscript.gif` | Query parameters, with page details from `Referer` | 1×1 GIF | | POST | `/events` | One JSON object | Plain text `ok` | | POST | `/append` | Same JSON object format as `/events` | Plain text `ok` | | GET | `/healthz` | None | Plain text `ok` | `/events` and `/append` are the same ingestion handler. Both default to `type: "pageview"`; the route name does not choose a row type. Set `type: "event"` for events and `type: "append"` for duration or scroll updates. There is no batch-array API. ## Send JSON The examples use a placeholder hostname. Replace it with your registered hostname; never use the public demo as a write target. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST https://analytics.bitgate.dev/events \ -H 'Content-Type: application/json' \ --data '{"hostname":"your-domain.example","type":"event","event":"signup","metadata":{"plan":"pro"}}' ``` ```typescript const hostname = process.env.EA_HOSTNAME; if (!hostname) throw new Error("Set EA_HOSTNAME to your registered hostname"); const response = await fetch("https://analytics.bitgate.dev/events", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hostname, type: "event", event: "signup", metadata: { plan: "pro" }, }), signal: AbortSignal.timeout(20_000), }); if (!response.ok) { throw new Error(`Collector ${response.status}: ${await response.text()}`); } console.log(await response.text()); ``` A successful JSON request returns `200` with the literal body `ok`, not JSON. Send JSON text: `application/json` is recommended, and JSON sent as `text/plain` by `sendBeacon` is also accepted; the handler does not enforce Content-Type. Curl's default user agent is classified as a bot, so a curl test can succeed without appearing in reports. Server-side visitor attribution uses the sending request's IP and effective user agent, not an end user's identity supplied in JSON. ### Append duration and scroll Appends create additional rows; they do not update an existing pageview. Use the original pageview's `id` or `page_id` as `original_id` for correlation. The collector does not require that ID or check that a matching pageview exists. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ -X POST https://analytics.bitgate.dev/append \ -H 'Content-Type: application/json' \ --data '{"hostname":"your-domain.example","type":"append","original_id":"pageview-id","duration":42,"scrolled":75}' ``` Duration is in seconds and scroll is a percentage. Appends are counted in their own ingestion-time range, not the original pageview's time. [Overview averages](https://totallytics.com/docs/stats#what-the-counts-mean) are calculated per append row, not per pageview. ## Payload fields The same fields work as query parameters on the pixels and as properties of the single JSON body on either POST route. Query values are strings. In JSON, scalar strings, numbers and booleans are converted to strings before most field processing; use the types below for predictable results. Unrecognized fields are ignored. Text limits below are truncation limits, not validation errors. Missing optional text defaults to an empty string unless noted. Numeric measurements are rounded to the nearest integer and clamped to the stated maximum; missing, non-finite or non-positive values become zero. ### Required and routing fields | Field | Type | Processing | | ---------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hostname` | string | Required and non-empty. Lowercased and truncated to 253 characters; not whitespace-trimmed. Use the exact registered hostname, without scheme, port or path. | | `type` | string | `pageview`, `event`, `append` or `error`; default `pageview` when missing or empty. Case-sensitive; truncated to 16 characters before validation. | | `event` | string | Required for `type: "event"`; otherwise ignored. Truncated to 256 characters, then non-ASCII-letter/digit runs become `_` and edge underscores are removed. Case is preserved; `Buy now!` becomes `Buy_now`. Empty after cleaning is an error. | | `path` | string | 2,048 characters. Defaults to `/` for a pageview and empty for other types. | | `query` | string | Page query string without the leading `?`, up to 2,048 characters. Parsed for UTM parameters. | | `referrer` | string | 2,048 characters. Use `hostname/path` without a URL scheme: the first slash-separated segment becomes the referrer hostname. | | `metadata` | object, array or string | Objects and arrays are JSON-serialized; strings are kept as supplied. Stored text is truncated to 4,096 characters and can therefore cease to be valid JSON. Not exposed by the stats API. | ### IDs and measurements | Field | Type | Processing | | ----------------------------------- | ------ | -------------------------------------------------------------------------------- | | `id` | string | Row ID, up to 64 characters | | `page_id` | string | Page correlation ID, up to 64 characters | | `session_id` | string | Session correlation ID, up to 64 characters; not used for visitor counting | | `original_id` | string | Original pageview correlation ID, up to 64 characters | | `duration` | number | Seconds; integer output from `0` to `86400` | | `scrolled` | number | Percentage; integer output from `0` to `100` | | `viewport_width`, `viewport_height` | number | Viewport dimensions; integer output from `0` to `65535` | | `screen_width`, `screen_height` | number | Screen dimensions; integer output from `0` to `65535` | | `error` | string | Error text, up to 2,048 characters; error rows have no dedicated stats breakdown | IDs do not provide deduplication or idempotency. Reusing an ID can create another row. ### Client context | Field | Type | Processing | | ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ua` | string | User agent, truncated to 512 characters when supplied. Falls back to the request's `User-Agent` header. Drives browser, OS, device, visitor and bot classification. | | `timezone` | string | Client timezone label, up to 64 characters; does not set row timestamps | | `language` | string | Language label, up to 16 characters | | `os_name`, `os_version` | string | Up to 64 characters each; non-empty supplied values override parsed OS values | | `brands` | string | Brand information serialized as a string, up to 256 characters | | `version` | string | Script version label, up to 32 characters | | `hostname_original` | string | Original hostname before an override, lowercased and truncated to 253 characters | ### Flags Flags are true for boolean `true`, number `1`, string `"true"` or string `"1"`; other values are false. Defaults are false except `https`, which defaults to true when absent. | Field | Meaning | | --------------- | ------------------------------------------------------------------------------------------------ | | `unique` | Stored uniqueness hint; does not control stats visitor counts | | `mobile` | Mobile hint; parsed device type also contributes to the stored mobile flag | | `bot` | Marks a bot. False does not override bot detection from the user agent. | | `brave`, `duck` | Browser-specific flags; not available as breakdown dimensions | | `https` | Whether the tracked page used HTTPS | | `collect-dnt` | Separate override, not a normal flag: only `true` or `"true"` bypasses a DNT skip; `1` does not. | ### Campaign fields | Field | Type | Processing | | -------------- | ------ | ----------------------------------------------------------- | | `utm_source` | string | Up to 256 characters; available as `utm_sources` in stats | | `utm_medium` | string | Up to 256 characters; available as `utm_mediums` in stats | | `utm_campaign` | string | Up to 256 characters; available as `utm_campaigns` in stats | | `utm_term` | string | Up to 256 characters; no stats breakdown | | `utm_content` | string | Up to 256 characters; no stats breakdown | For each field, a non-empty UTM value parsed from `query` takes precedence over the explicit top-level value. Other query parameters are stored within `query` but do not create report dimensions. ### Server-derived fields Row timestamps are assigned during ingestion. Country comes from the incoming request's network context. Visitor identifiers are derived from the UTC day, site, incoming IP and effective user agent, and rotate daily. Payload fields such as `timestamp`, `ts`, `ip`, `country` and `visitor_id` do not override them. The tracker's `time` cache-buster and `sri` field are also ignored by the collector. ## Pixels and script ### Pageview pixel `GET /simple.gif` reads the payload fields from its query string. A valid request returns a 1×1 GIF, including for event, append or error types. An invalid payload returns a text error instead of an image. ```curl curl --fail-with-body --silent --show-error --max-time 20 \ --get https://analytics.bitgate.dev/simple.gif \ --data-urlencode 'hostname=your-domain.example' \ --data-urlencode 'type=pageview' \ --data-urlencode 'path=/pricing' \ --output /dev/null ``` For metadata on a pixel, send a URL-encoded JSON string rather than an object. A GET pixel is a collection request, not a read-only stats request. ### No-JavaScript pixel `GET /noscript.gif` has the same payload format and GIF response. When absent from the query, `hostname`, `path`, `https` and `query` are derived from the page URL in the `Referer` header. Explicit parameters take precedence, even when empty. `type` defaults to `pageview`. The incoming `Referer` is the page being tracked, not that page's acquisition referrer. The collector does not infer the acquisition `referrer` field from it. Browser referrer policies may omit the header or reduce it to an origin; then the hostname must be supplied explicitly or the path may become `/`. ### Browser script `GET /latest.js` serves JavaScript with `Cache-Control: public, max-age=86400, stale-while-revalidate=604800`. Install it with the configuration described in [Installation](https://totallytics.com/docs/installation) and use [Events](https://totallytics.com/docs/events) for browser event calls. Fetching the script itself does not record a pageview. ## Receipt and filtering A collector `200` acknowledges the handler's response, not a durable insert. Storage runs in the background; a later storage failure is not returned to the caller. Normally, unregistered hostnames are discarded. Registration lookup failures can bypass that check, so it is not an authorization boundary. [Site caching](https://totallytics.com/docs/sites#errors-and-caching) can delay registration changes. There is no idempotency guarantee. Do not automatically retry event or append requests after an ambiguous network failure: the first request may already have been processed. Inspect the response and [reports](https://totallytics.com/docs/stats) separately when validating an integration. If `DNT: 1` or `X-Do-Not-Track: 1` is present, collection is skipped and the normal success response is returned, unless `collect-dnt` is `true`. This check occurs before payload-field validation; POST bodies must still parse as JSON. The browser script also checks browser Do Not Track before sending. Bot rows can be stored but are excluded from reports. Detection checks the `bot` flag and user-agent matches for `bot`, `spider`, `crawl`, `monit`, `curl`, `wget`, `python-requests`, `node-fetch`, `axios` or `headless`, case-insensitively. ## Responses and CORS | Status | Body | Meaning | | ------ | -------------------------- | -------------------------------------------------------------------------------- | | 200 | `ok` | JSON collector receipt or `/healthz` liveness response | | 200 | GIF bytes | Pixel receipt | | 400 | `POST required` | `/events` or `/append` was called with a method other than POST or OPTIONS | | 400 | `invalid JSON` | POST body did not parse as a non-null JSON object; arrays are not a batch format | | 400 | `hostname is required` | No usable hostname | | 400 | `unsupported type: ` | Row type is not supported | | 400 | `event name is required` | Event name is missing or empty after cleaning | | 204 | Empty | OPTIONS preflight | An array is not processed as a batch: it ordinarily reaches hostname validation and returns `hostname is required`. Collector errors are plain text, not the product API's JSON error envelope. Pixel responses use `image/gif` and `Cache-Control: no-store, no-cache, must-revalidate`; JSON ingestion responses use `text/plain; charset=utf-8` and `Cache-Control: no-store`. Collector responses and OPTIONS preflights provide `Access-Control-Allow-Origin: *`, methods `GET, POST, OPTIONS`, allowed headers `Content-Type`, and a preflight max age of `86400` seconds. They do not allow credentials or arbitrary custom request headers. This does not enable cross-origin reads of `/api/*`. `GET /healthz` returns `ok` without checking storage or database readiness, and its response has no collector CORS headers. Use it to check that the HTTP handler responds, not that events have been stored. --- Source: https://totallytics.com/docs/agents # Set up with an AI agent Give your agent a hostname, repository access, and permission to open a pull request or deploy. This workflow covers site registration, a single tracking tag, and proof that data reached your dashboard. Machine-readable references: [documentation index](https://totallytics.com/llms.txt), [full documentation](https://totallytics.com/llms-full.txt), [OpenAPI specification](https://totallytics.com/openapi.json), and [this guide as Markdown](https://totallytics.com/docs/agents.md). ## Copy a setup prompt Replace the bracketed values. Supply the access token separately, following the authentication step below. ```text Install Totallytics in this project. Hostname: [example.com] Repository or workspace: [project location] Deployment permission: [open a PR only / deploy after checks pass] Replace an existing Simple Analytics install: [yes / no] One clearly named verification event is permitted: [yes / no] Read these first: https://analytics.bitgate.dev/llms.txt https://analytics.bitgate.dev/llms-full.txt https://analytics.bitgate.dev/openapi.json https://analytics.bitgate.dev/docs/agents.md 1. Inspect the actual app root, generated-page templates, existing analytics, event calls, script settings, CSP, and any first-party analytics proxy. Do not remove unrelated analytics or change the site's privacy settings. 2. Use TT_TOKEN from the execution environment for owner API calls. Check GET /api/sites and reuse the exact owned hostname, or register it with POST /api/sites. A 409 is not proof of ownership. Do not enable public dashboards or invent service keys. If sign-in is missing, finish safe repository work and report the account step as blocked. 3. Add https://analytics.bitgate.dev/latest.js once in each shared HTML document/root, with data-hostname matching the registered hostname. Keep production collection out of local/preview builds. Update all relevant entrypoints and generator templates, not only the homepage. 4. Preserve supported settings and event calls. Do not combine automatic pageviews with extra route tracking. If replacing Simple Analytics, swap its core tag and optional pixel rather than adding a second default tag. Audit auto-events helpers and proxy destinations separately. 5. Merge the analytics origin into script-src, connect-src and img-src; also update script-src-elem when present. Preserve the existing policy. 6. Run the project's build/checks. Deploy only with the permission above; otherwise open a PR and leave production verification explicitly pending. 7. On the deployed registered hostname, verify a real browser's pageview request and the matching stored data. If permitted, send one unique verification event and poll authenticated breakdown reads for its name. Do not repeatedly send events, fake a non-bot result, or treat HTTP 200 as proof of ingestion. Report a missing real-browser check as a blocker. 8. Report hostname/ownership, changed files, build result, PR or deployment, request destination, stored-data evidence and any unfinished steps. Treat historical migration as a separate task, not a result of adding a tag. If history is requested, read /docs/imports and the migration guide first. Confirm a non-overlapping UTC range, source credentials and existing data before creating a job. Verify stored counts, not just its completed status. ``` ## 1. Sign in and confirm the site Sign in to [Totallytics](https://totallytics.com/login), reopen [Authentication](https://totallytics.com/docs/authentication), and click **Copy access token**. Pass that short-lived Firebase user ID token to the agent's server-side environment as `TT_TOKEN`. Copy a fresh token if it expires. There is no permanent service-key setup to substitute for user sign-in. The token is for site management and private stats reads, not the tracking tag. Keep it out of source files, browser bundles, and public environment variables such as `VITE_*` or `NEXT_PUBLIC_*`. Choose the exact hostname before editing. Use `example.com`, not `https://example.com/path` or a hostname with a port. Registration trims whitespace and lowercases, but **does not remove `www`**. To group `www.example.com` into `example.com`, register `example.com` and use that value in `data-hostname`. Register subdomains separately when you want separate reporting. These terminal examples use Bash, curl, and jq: ```bash EA_ORIGIN='https://analytics.bitgate.dev' EA_HOSTNAME='example.com' : "${TT_TOKEN:?Copy an access token from the authentication guide first}" curl --fail-with-body --silent --show-error --retry 2 --max-time 30 \ "$EA_ORIGIN/api/sites" \ --header "Authorization: Bearer $TT_TOKEN" ``` Look for an exact match in the returned `sites` array. This list is owner-scoped; a publicly readable dashboard is not proof of ownership. If the hostname is absent, create it: ```bash curl --fail-with-body --silent --show-error --max-time 30 \ "$EA_ORIGIN/api/sites" \ --header "Authorization: Bearer $TT_TOKEN" \ --header 'Content-Type: application/json' \ --data "$(jq -n --arg hostname "$EA_HOSTNAME" '{hostname: $hostname}')" ``` A successful create returns `201` with `{"hostname":"example.com","verified_at":null,"verify_token":"tt-verify-…"}`. On `409`, re-read the owned list and proceed only if the hostname is now there. After an uncertain network failure, read before retrying the create. Resolve ownership conflicts rather than choosing a different hostname that your tag will never send. **Ownership must be verified before the site collects data or can be made public.** The collector discards traffic for unverified sites. If you control the site's DNS or web root, complete verification now: publish the returned `verify_token` as a TXT record at `_totallytics-verify.{hostname}` or as the body of `https://{hostname}/.well-known/totallytics-verify.txt`, then call `POST /api/sites/{hostname}/verify`. If you cannot publish either proof from this environment, leave the site unverified and report verification as a pending manual step. See [Site management — verify ownership](https://totallytics.com/docs/sites#verify-ownership). Unauthorized private reads and mutations return a uniform `404 unknown site` whether the hostname is unregistered or owned by someone else. See [Site management](https://totallytics.com/docs/sites) for limits and settings. Do not make a dashboard public to bypass an authentication problem. ## 2. Install once in the shared document Use the registered hostname in the tag: ```html ``` Put it in the shared document or root layout, not a component that remounts on navigation. - **Static sites and Vite:** inspect every HTML entrypoint, including separate marketing pages. Update generators as well as generated output. - **React and SSR frameworks:** use the persistent document/root layout, such as Next.js `app/layout.tsx` or Remix `app/root.tsx`. Preserve attributes when using a framework script component. Avoid inserting the tag on every render or effect. - **Client-side routing:** the tag already tracks distinct paths on `pushState` and `popstate`. Hash routing needs `data-mode="hash"`. It does not directly listen to `replaceState`, and query-only changes are not new page paths. - **Manual pageviews:** `window.sa_pageview()` exists only with `data-auto-collect="false"`. Wait for load, then handle both initial and subsequent views yourself. Repeated consecutive calls for the same path are suppressed. See [Installation](https://totallytics.com/docs/installation). If you need a JavaScript-disabled fallback, put this optional pixel in the **server-rendered body**: ```html ``` The pixel does not inherit `data-hostname`; its query parameter sets the hostname separately. The page path normally comes from the request's `Referer`. A stricter referrer policy can remove that information; supply the required values from the server-rendered template or omit the fallback. JSX spells the attribute `referrerPolicy`. A client-only component cannot provide a useful no-JavaScript fallback. Keep the production tag out of local and preview builds, or use a separate registered staging hostname. The current script can send from localhost, especially with a hostname override. Local requests are not proof that the deployed site works and can contaminate production counts. ## 3. Update CSP without replacing it Merge the analytics origin into the site's existing directives: ```text script-src 'self' https://analytics.bitgate.dev; connect-src 'self' https://analytics.bitgate.dev; img-src 'self' https://analytics.bitgate.dev; ``` These are policy fragments, not a replacement for your whole CSP. If `script-src-elem` is defined separately, update that too. Preserve existing nonces, hashes, and other sources; adding `'unsafe-inline'` is not required for the external tag. `script-src` covers `/latest.js`. `img-src` covers pageviews and events sent through `/simple.gif`, fallback append requests, and `/noscript.gif`. **`connect-src` is required for `navigator.sendBeacon()` to `/append`**, which carries duration and scroll updates. Inspect policies set by the app, server, CDN, and HTML, not just one config file. If replacing Simple Analytics or using a proxy, follow the [migration guide](https://totallytics.com/docs/migrate-simple-analytics). Two default core tags share `sa_loaded` and `sa_event`; whichever loads first can prevent the other from running. ## 4. Verify stored data, not just a request Run the project's checks and deploy only when authorized. Open a real route on the registered deployed site in a normal browser, then check: 1. `/latest.js` returns JavaScript, and the document contains one intended core tag. 2. `/simple.gif` sends `type=pageview`, the registered `hostname`, and the expected `path`. 3. Navigation produces one pageview per intended distinct path. Preserve the network log and leave the page to inspect `/append` or an append fallback. 4. The corresponding page row increases in the dashboard or `dim=pages` breakdown, within the recorded test window. Check CSP errors, blockers, and Do Not Track when a request is missing. Headless automation and bot user agents can be excluded from normal stats, so an automated network check alone cannot prove visible ingestion. Do not spoof a human visit to make a test pass. The collector acknowledges before background storage finishes. A `200`, an event callback, a healthy `/healthz`, or a nonzero live count does **not** prove your test was stored. ### Confirm an identifiable test event If the owner permits one test event, capture a timestamp and print a unique event call: ```bash EA_FROM="$(date -u +%s)" EA_CHECK="ea_setup_${EA_FROM}" printf 'window.sa_event("%s")\n' "$EA_CHECK" ``` After the tracking script loads, run the printed call **once** in that site's real-browser console. It leaves a labelled test event in the site's data. Then query from the terminal: ```bash set -o pipefail curl --fail-with-body --silent --show-error --retry 2 --max-time 30 \ --get "$EA_ORIGIN/api/sites/$EA_HOSTNAME/breakdown" \ --header "Authorization: Bearer $TT_TOKEN" \ --data-urlencode 'dim=events' \ --data-urlencode "from=$EA_FROM" \ --data-urlencode "to=$(($(date -u +%s) + 1))" \ --data-urlencode 'limit=100' \ | jq -e --arg name "$EA_CHECK" \ '.rows[] | select(.name == $name and .value > 0)' ``` The matching `{name, value, visitors}` row is evidence of receipt. If it is absent, retry the **read**, not the event, for a bounded period such as one minute. The breakdown returns at most the top 100 names; keep the test window narrow on busy sites. If you cannot establish receipt, report ingestion as unverified and use [Troubleshooting](https://totallytics.com/docs/troubleshooting). See [Stats](https://totallytics.com/docs/stats) for range semantics. ## 5. Hand back an exact outcome Report the registered hostname and ownership check, changed files and root placement, CSP changes, build result, PR or deployment URL, and the actual request and stored-row evidence with its time window. If sign-in, deployment permission, or a real-browser check is missing, say **code ready, ingestion unverified** and name the remaining step. Do not claim historical data was migrated: [historical backfill is a separate workflow](https://totallytics.com/docs/migrate-simple-analytics#historical-data). --- Source: https://totallytics.com/docs/migrate-simple-analytics # Migrate from Simple Analytics Replace the live tracker, verify new data, and handle historical data separately. Supported script settings and `sa_event()` calls can carry over. ## 1. Audit the existing install and choose a cutover Search all app roots, HTML templates, generated-page builders, tag-manager entries, and proxy rules. Look for `simpleanalytics`, `sa_settings`, `sa_event`, `sa_pageview`, `noscript.gif`, `auto-events.js`, and any first-party `proxy.js`. Record the hostname used in your Simple Analytics dashboard, script attributes, event names, and any manual pageview logic. Check separate marketing pages and cached HTML, not just the main application. If historical continuity matters, plan the [historical data transfer](#historical-data) before switching. Record the actual cutover time in UTC and keep Simple Analytics export access until your archive is verified. Installing the new tag starts new collection; it does not copy old data. ## 2. Register the matching hostname [Sign in](https://totallytics.com/login) and add the site in [your dashboard](https://totallytics.com/app), or follow the [agent setup guide](https://totallytics.com/docs/agents) to register it through `POST /api/sites` with an Totallytics access token. Use a bare hostname, such as `example.com`. Totallytics lowercases hostnames but **does not strip `www`**. If both `www.example.com` and `example.com` should report under one site, explicitly set `data-hostname="example.com"`. If you want separate reports, register each hostname separately. Match the hostname used for historical exports deliberately. Do not assume that a hostname accepted by Simple Analytics will be grouped the same way here. The script and optional pixel need the same intended destination. See [Site management](https://totallytics.com/docs/sites) and Simple Analytics' [hostname override documentation](https://docs.simpleanalytics.com/overwrite-domain-name). Site ownership and dashboard visibility are configured separately in Totallytics. A Simple Analytics API key cannot create or manage Totallytics sites. ## 3. Swap the script and optional pixel Replace the existing core tag, preserving supported attributes. Before: ```html ``` After, with explicit attribution to `example.com`: ```html ``` Keep the tag once per shared HTML document. The optional `noscript` pixel belongs in the server-rendered body and does not inherit the script's settings. Its page path normally comes from `Referer`; retain an appropriate referrer policy or provide values explicitly from your template. In JSX, use `referrerPolicy`. Update each independent entrypoint and the source of generated pages, then rebuild. Confirm the live HTML changed after deployment and any cache invalidation. **Do not just add a second default tag for a comparison period.** The core scripts share `window.sa_loaded` and `window.sa_event`. The first script to execute can make the second exit, so neither pageview comparison nor custom-event delivery is reliable with a naive dual install. An old Simple Analytics `integrity` hash will not match Totallytics' modified script. Review that policy rather than carrying the hash over. Totallytics does not serve Simple Analytics' alternate `latest.dev.js`, light, or SRI script endpoints. ## 4. Preserve settings and event behavior Totallytics serves a vendored Simple Analytics v11 script with a different collector destination. It supports these configuration patterns: | Setting | Migration check | | ------------------------------------------------- | ------------------------------------------------------------------------ | | `data-hostname` | Use the exact registered destination. | | `data-ignore-pages` | Keep intended excluded paths and wildcard patterns. | | `data-allow-params` | Retain the permitted query parameters, not arbitrary URL data. | | `data-ignore-metrics` | Preserve deliberately excluded metrics. | | `data-strict-utm` | Preserve the choice of strict UTM parameter names. | | `data-non-unique-hostnames` | Keep the configured referrer hostnames treated as non-unique. | | `data-path-overwriter`, `data-metadata-collector` | Keep the named global callbacks available before collection. | | `window.sa_settings` | Keep configuration before the async tag; it can override tag attributes. | This compatibility applies to the vendored script, not every feature or future release of Simple Analytics. ### SPA and manual pageviews Automatic collection handles the initial load, `history.pushState`, and `popstate` when the tracked path changes. Hash routing needs `data-mode="hash"`. There is no direct `replaceState` listener; a query-only change does not create a new page path. Do not add manual route tracking on top of automatic collection. If the existing integration uses `data-auto-collect="false"`, keep its initial and navigation calls to `window.sa_pageview()` and wait for the script to load. That function is only exposed in manual mode. Consecutive identical tracked paths are suppressed, even when called manually. See [Installation](https://totallytics.com/docs/installation) and the upstream [custom pageview guide](https://docs.simpleanalytics.com/trigger-custom-page-views). ### Custom and automatic events Existing custom calls keep their shape: ```javascript if (typeof window.sa_event === "function") { window.sa_event("newsletter_signup", { source: "footer" }); } ``` This guard avoids an error but skips the event if the tag is not ready. Preserve a pre-load queue if your application already needs one, or wait for load. Keep browser globals out of server-side execution. Event names replace non-alphanumeric runs with underscores and trim outer underscores. A callback is not proof of storage; it can also run after a local validation failure without sending a request. Simple Analytics' [automated-events helper](https://docs.simpleanalytics.com/automated-events) is a separate script. **Totallytics serves neither `/auto-events.js` nor `/auto.js`.** Do not change only the hostname of that helper. Replace required outbound, download, or email-click tracking with explicit [event calls](https://totallytics.com/docs/events), or audit and test your existing helper separately. ## 5. Update CSP and proxy destinations Add the Totallytics origin to the existing policy: ```text script-src 'self' https://analytics.bitgate.dev; connect-src 'self' https://analytics.bitgate.dev; img-src 'self' https://analytics.bitgate.dev; ``` Merge these sources; do not replace the whole CSP. Update `script-src-elem` if it is defined, and preserve existing nonces and hashes. No new `'unsafe-inline'` allowance is required for the external tag. `/latest.js` needs script permission. `/simple.gif` pageviews and events, append fallbacks, and `/noscript.gif` need image permission. Duration and scroll updates use `navigator.sendBeacon()` to **`/append`, covered by `connect-src`**. Remove old Simple Analytics origins only after confirming no retained component needs them. ### First-party proxies need a separate audit A local-looking `/proxy.js` may still send everything to Simple Analytics. Check its upstream source and the actual collector requests. The [upstream proxy guide](https://docs.simpleanalytics.com/proxy) helps identify routes you may already have. The simplest cutover uses the direct Totallytics tag and retires unused Simple Analytics proxy routes. Totallytics' tag has a fixed collector origin; **proxying `/latest.js` alone does not make collection first-party**. There is no Totallytics proxy-generator endpoint or supported runtime collector-base setting. If you must keep first-party collection, treat it as a separate integration: the script's actual destination and proxy routes must agree. Preserve query strings for `/simple.gif` and `/noscript.gif`, and methods and bodies for `/append` or server-side `/events`. Do not cache collector responses. Recheck attribution and visitor counts after proxy changes rather than assuming header or IP behavior stayed identical. ## 6. Verify the live cutover Build and deploy the updated site. On a real route in a normal browser, inspect `/latest.js` and the next `/simple.gif` request: check the destination, `type=pageview`, hostname, and path. Test one SPA navigation and an existing custom-event action. Check append traffic when leaving the page if duration and scroll matter to your setup. Confirm the matching data in the dashboard or authenticated breakdown API. Use a narrow time window and compare the page count before and after a visit, or send one permitted, uniquely named verification event. The [agent verification workflow](https://totallytics.com/docs/agents#4-verify-stored-data-not-just-a-request) includes commands. A `200` response is only an acknowledgement, not proof that a row was stored. Localhost, bot/headless tests, Do Not Track, blockers, and an unregistered hostname can make a network-only check misleading. Poll reads while waiting for asynchronous processing; do not repeatedly send the test event. ### Update reporting integrations separately Totallytics does not mirror Simple Analytics' `/{hostname}.json` [Stats API](https://docs.simpleanalytics.com/api/stats). Replace those clients with the [Totallytics Stats API](https://totallytics.com/docs/stats): `/api/sites/{hostname}/overview` and `/api/sites/{hostname}/breakdown`, using Unix-second `from`/`to` parameters and an owner bearer token for private sites. Do not forward Simple Analytics' `Api-Key`, `User-Id`, `fields`, or date-format `start`/`end` parameters unchanged. Visitor calculation differs, and Totallytics' `avg_duration_s` is an average rather than Simple Analytics' median time on page. Exact metric parity is not a migration success criterion. ## Historical data Site owners can import historical analytics directly from `/app/{hostname}/settings` under **Import data**. The only supported import provider is SimpleAnalytics. CSV uploads and ongoing sync are not supported. ### Import workflow 1. Sign in as the site owner and open `/app/{hostname}/settings`. 2. Under **Import data**, select provider **SimpleAnalytics**. 3. Fill in **User ID**, **API key**, **Source hostname**, **From**, and **To**. 4. Click **Start import**. Simple Analytics API credentials serve as source configuration and are distinct from Totallytics Bearer tokens (see [Authentication](https://totallytics.com/docs/authentication)). The source hostname can differ from your Totallytics site to accommodate domain migrations. Credentials are validated against Simple Analytics before the import queues. Once queued, the background task runs independently of browser sessions. The settings panel polls every 3 seconds while active, showing `queued`, `running`, `completed`, `failed`, or `canceled` statuses alongside chunk progress, `rows_imported`, and errors. Usage is limited to 1 active import per website and 20 new jobs per user per rolling 24 hours. The source API key is omitted from job responses. Its stored value is cleared when the job completes; failed and canceled jobs retain it for retry. ### Cutover and date boundaries - Date ranges use inclusive UTC days. Start dates must be on or after 2010-01-01, with at most 1,826 days between the two dates. - The planner rounds the start date down to the first day of the selected month. Choose the first of the month deliberately; starting mid-month may pull in earlier data from that month. - To cut over tracking cleanly, pick midnight UTC on your switch date and set the import end date to the preceding day. Importing the full cutover day after an intraday switch can overlap live tracking. ### Deduplication and data fidelity Totallytics does not maintain a deduplication ledger. Re-importing overlapping periods creates duplicate rows. Inspect existing reports and maintain external logs before scheduling runs. - **Counts**: The counter covers completed chunks and includes pageviews, custom events, and engagement append records. It is not a pageview or visitor total, and partial writes from an interrupted chunk may not be included. - **Fidelity**: Visitor identities are approximated. Original session continuity and custom event metadata are not restored. Source UUIDs are not persistent deduplication keys. - **Idempotency**: Completed chunks are recorded, but interrupted chunks can replay partial writes. `Cancel` stops later work without rolling back stored rows or aborting a chunk already writing. `Retry` resumes from the last cursor on failed or canceled runs; verify existing counts first. - **API usage**: Do not backfill data using `POST /events`, which discards historical timestamps. Use the [Imports API](https://totallytics.com/docs/imports) for historical records. ### Verify the import Verify imports by reviewing specific paths, events, and matching UTC intervals. Visitor numbers will not match Simple Analytics exactly. To preserve full raw metadata, download standalone archives directly from Simple Analytics via their [website export interface](https://simpleanalytics.com/select-website/export) or [data points export API](https://docs.simpleanalytics.com/api/export-data-points). Export pageviews and events separately. Select metadata fields explicitly if you need them in the archive, and inspect the CSV header and a sample before closing your Simple Analytics account. The source Stats API contains aggregated reports, not a replacement for raw exports. --- Source: https://totallytics.com/docs/troubleshooting # Troubleshooting Resolve common integration, script delivery, and API authorization issues. ## Diagnostic checklist If pageviews or events do not appear in your dashboard, check these common causes: ### 1. Verify hostname registration The collector normally ignores unregistered hostnames, even when it returns HTTP 200. Register the hostname before testing. Collector instances can cache a missing hostname for about a minute. Verify that the hostname configured in your script matches the hostname in [/app](https://analytics.bitgate.dev/app): - Hostnames are case-insensitive and trimmed, but `www.example.com` and `example.com` are treated as distinct domains. - To combine apex and `www` traffic, set the same explicitly registered `data-hostname` on both versions of your site. Without that setting, the script uses `location.host`. ### 2. Verify script source Ensure your script loads from `https://analytics.bitgate.dev/latest.js`. Stale scripts cached from third-party mirrors or legacy Simple Analytics endpoints will not reach your Totallytics collector. ### 3. Duplicate script guards The default tracker sets `window.sa_loaded = true` when executed. If your page loads a second copy of the script, or retains an old analytics tag, the secondary script halts immediately. Remove duplicate tracking tags. ### 4. Ad blockers, DNT, and automated browsers - Do Not Track: The script checks `navigator.doNotTrack`. By default, `"1"` stops normal collection. - Browser extensions: Content blockers may intercept `latest.js` or `simple.gif`. - Headless automation: The script marks `navigator.webdriver = true` traffic as automated; matching user-agents are also classified as bots. Bot rows do not appear in normal visitor and pageview totals. Always test using a real, unblocked browser profile. ### 5. Content Security Policy (CSP) Check your browser console for CSP violation notices. Ensure `https://analytics.bitgate.dev` is included in `script-src`, `img-src`, and `connect-src` directives. If you use `script-src-elem`, add the origin there as well. ### 6. Processing delays An HTTP 200 response from the collector signals successful receipt, not instantaneous persistence. Aggregated dashboard metrics update asynchronously. Check `GET /api/sites//breakdown?dim=pages` across an active date range rather than expecting zero-latency counters. ## HTTP status codes ### 400 Bad Request - **Collector (`/events`)**: Returns plain text errors such as `invalid JSON`, `hostname is required`, `event name is required`, or `unsupported type`. A non-POST request also returns `400`, with `POST required`. - **API (`/api/*`)**: Check the response’s `error` field. Invalid ranges, invalid hostnames on registration, and unknown breakdown dimensions return `400`; unknown query keys are generally ignored. Send valid JSON for all writes: an unreadable PATCH body is currently treated as no change rather than rejected. Statistics ranges are at most 400 days and use Unix seconds, not milliseconds. Import requests instead use date-only strings and their own [range rules](https://totallytics.com/docs/imports). Use one of the 10 [supported dimensions](https://totallytics.com/docs/stats). Supply a whole-number breakdown `limit`. ### 401 Unauthorized The request lacks a valid Firebase ID token in the `Authorization: Bearer ` header. Refresh your token in [/docs/authentication](https://totallytics.com/docs/authentication). ### 403 Forbidden The token is valid, but the account does not own the requested private site. ### 404 Not Found The requested site or import job was not found. Verify the exact hostname and job ID. ### 409 Conflict Returned when attempting to add a hostname via `POST /api/sites` that is already claimed. Check your site list via `GET /api/sites` before attempting registration. Imports also return `409` for an already-active import on that site or an invalid cancel/retry transition; see [Imports](https://totallytics.com/docs/imports). ### 429 Too Many Requests A user may create at most 20 new import jobs in a rolling 24-hour window. Wait before creating more; do not submit duplicate jobs to retry existing work. ### 500 Internal Server Error An unexpected server-side error occurred. Retry safe read requests with a short, bounded backoff. Do not blindly repeat event POSTs or site mutations after an ambiguous failure; inspect the current state first. ## Operational behavior - **Rate limiting**: Statistics and collection have no documented request quota or rate-limit headers. Historical imports have the job limit above. This is not an unlimited-throughput guarantee. Bound concurrency and handle upstream/network errors. - **Health check**: The `/healthz` endpoint confirms worker process responsiveness. It does not check database connectivity or collector readiness. - **Overview vs Breakdown rollups**: Overview totals read hourly rollups; its series uses hourly buckets for ranges up to four days and daily buckets for longer ranges, with the requested `tz`. Breakdown filters raw rows by the supplied timestamps. Refer to the [Stats Reference](https://totallytics.com/docs/stats) for timestamp alignment details. - **Site capacity**: Each account can register a maximum of 50 sites. - **Deleted sites**: Deletion removes the site registration, not its historical rows. Collection and visibility changes may take several minutes to propagate through cached site lookups. See [Sites](https://totallytics.com/docs/sites). - **Discrepancies with Simple Analytics**: Totallytics uses unique daily visitor hashes. Calculated visitor counts will not match Simple Analytics figures exactly. To import historical records, consult the [Migration Guide](https://totallytics.com/docs/migrate-simple-analytics). ## Import completed with zero rows Check the source hostname, including `www`, the selected UTC dates, and whether Simple Analytics returns data for that range. Inspect `chunks_done`, `chunks_total`, and `rows_skipped`; a completed status alone is not proof that expected history arrived. Keep your Simple Analytics account and exports until actual report counts are verified. Record the job ID, requested range, and source hostname if the result is unexpected. Do not start repeated overlapping jobs as a diagnostic step: new jobs and interrupted-chunk retries can duplicate records.