# 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](/docs/sites#register-a-hostname) before sending traffic.

Base URL: `https://analytics.bitgate.dev`. [OpenAPI document](/openapi.json) | [Install the tracker](/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](/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](/docs/installation) and use [Events](/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](/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](/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: <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.
