# API requests

Send request metrics with a site API key, manage keys, and read request stats per endpoint, status, client and consumer. For the middleware setup, see [API analytics](/docs/api-analytics).

Base URL: `https://totallytics.com`. [Download OpenAPI](/openapi.json) or [read this page as Markdown](/docs/api-requests.md).

## Access

| Method | Route                                      | Result                                        |
| ------ | ------------------------------------------ | --------------------------------------------- |
| POST   | `/api/ingest`                              | Send a batch with a site API key (`202`)      |
| GET    | `/api/sites/{hostname}/keys`               | List active keys                              |
| POST   | `/api/sites/{hostname}/keys`               | Create a key (`201`)                          |
| DELETE | `/api/sites/{hostname}/keys/{id}`          | Revoke a key                                  |
| GET    | `/api/sites/{hostname}/requests/overview`  | Totals, previous period and time series       |
| GET    | `/api/sites/{hostname}/requests/breakdown` | Endpoints, status codes, clients or consumers |
| GET    | `/api/sites/{hostname}/requests/errors`    | 4xx and 5xx grouped per endpoint              |
| GET    | `/api/sites/{hostname}/requests/samples`   | Latest individual 4xx and 5xx requests        |

Site API keys (`tt_...`) only work on `/api/ingest`: they cannot read stats or manage keys. Everything else needs the site owner's Firebase ID token from [Authentication](/docs/authentication), also on public sites. A missing or expired token returns `401` `sign in required`, and a hostname that is not registered to your account returns `404` `unknown site`. Responses are JSON.

## Send request metrics

`POST /api/ingest`

Send request counts aggregated per minute, plus optional samples of failed requests. The [middleware](/docs/api-analytics#install-the-middleware) does all of this for you; call the endpoint directly from other languages.

| Header          | Value                                       |
| --------------- | ------------------------------------------- |
| `Authorization` | `Bearer tt_...`, with a key from site settings |
| `Content-Type`  | `application/json`                          |

```curl
now=$(date +%s)
curl --fail-with-body --silent --show-error --max-time 20 \
  -X POST https://totallytics.com/api/ingest \
  -H "Authorization: Bearer ${TOTALLYTICS_API_KEY:?Create a key in site settings}" \
  -H 'Content-Type: application/json' \
  --data @- <<EOF
{
  "v": 1,
  "batch_id": "manual-$now",
  "metrics": [
    {
      "minute": $((now / 60 * 60)),
      "method": "GET",
      "route": "/users/:id",
      "status": 200,
      "count": 12,
      "duration_ms_sum": 845.2
    }
  ]
}
EOF
```

Success is `202` with the number of rows queued for storage:

```json
{ "accepted": { "metrics": 1, "errors": 0 }, "rejected": 0 }
```

### Batch

| Field      | Type    | Rules                                                                                     |
| ---------- | ------- | ----------------------------------------------------------------------------------------- |
| `v`        | integer | Wire version, always `1`                                                                  |
| `batch_id` | string  | 8-64 characters of `A-Z`, `a-z`, `0-9`, `_` and `-`; new for every batch of new data     |
| `sdk`      | string  | Optional, informational                                                                   |
| `metrics`  | array   | Optional, aggregated rows; the first 5,000 are read                                      |
| `errors`   | array   | Optional, individual 4xx and 5xx requests; the first 200 are read                         |

### Metric rows

| Field             | Type    | Rules                                                                                                  |
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `minute`          | integer | Unix seconds, floored to the minute; up to 7 days old or 5 minutes ahead                               |
| `method`          | string  | HTTP method, uppercased; letters only, up to 16, and `OTHER` when nothing is left                      |
| `route`           | string  | Route template such as `/users/:id`; raw paths are [templated](/docs/api-analytics#route-templates)    |
| `status`          | integer | 100-599                                                                                                |
| `count`           | integer | Requests in this row, 1 to 1,000,000,000                                                               |
| `duration_ms_sum` | number  | Sum of their durations in milliseconds, 0 or more                                                      |
| `histogram`       | object  | Optional latency buckets as `{ "<bucket>": <count> }`; see [Latency buckets](#latency-buckets)        |
| `user_agent`      | string  | Optional `User-Agent` of the caller, up to 512 characters, classified into a client and version        |
| `consumer`        | string  | Optional opaque caller id, up to 128 characters                                                        |

Rows that share minute, method, route, status, client, client version and consumer are merged. Counts add up across batches, so send each request once.

### Error rows

| Field         | Type    | Rules                                                                              |
| ------------- | ------- | ---------------------------------------------------------------------------------- |
| `ts`          | integer | Unix milliseconds of the request; up to 7 days old or 5 minutes ahead              |
| `method`      | string  | As in metric rows                                                                  |
| `route`       | string  | Route template; falls back to `path`                                               |
| `path`        | string  | Raw path, up to 512 characters; query string and fragment are removed              |
| `status`      | integer | 400-599                                                                            |
| `duration_ms` | number  | Duration in milliseconds, capped at 24 hours                                       |
| `user_agent`  | string  | Optional, as in metric rows                                                        |
| `consumer`    | string  | Optional, as in metric rows                                                        |
| `message`     | string  | Optional error message, up to 1,000 characters                                     |

Rows that fail these rules, and rows past the first 5,000 metrics or 200 errors, are skipped and counted in `rejected`. The rest of the batch is still stored. Longer strings are cut, not rejected.

### Latency buckets

`histogram` maps a bucket index to a request count. A duration of 1 ms or less goes into bucket `0`; anything longer into:

```typescript
const bucket = Math.min(Math.ceil(Math.log(ms) / Math.log(1.08)), 250);
```

Percentiles are read from these buckets and are accurate to within about 4%. Without a `histogram`, a row's requests are placed in the bucket of their average duration. Invalid bucket entries are ignored.

### Responses and retries

| Status | Message                                                  | What to do                                         |
| ------ | -------------------------------------------------------- | -------------------------------------------------- |
| 202    | none                                                     | Done                                               |
| 400    | `invalid JSON`, or a rule the batch breaks               | Fix the batch; do not retry it                     |
| 401    | `missing or malformed API key`                           | Send `Authorization: Bearer tt_...`                |
| 401    | `invalid or revoked API key`                             | Create a new key; do not retry                     |
| 405    | `POST required`                                          | Use `POST`                                         |
| 413    | `payload too large`                                      | Split into smaller batches, each with a new `batch_id` |
| 503    | `ingest temporarily unavailable, retry the same batch`   | Retry with the identical body                      |

The batch-level `400` messages are `body must be a JSON object`, `unsupported wire version, expected v: 1`, `batch_id must be 8-64 characters of A-Z, a-z, 0-9, _ or -`, `metrics must be an array` and `errors must be an array`. Bodies over 4 MiB return `413`.

Retry `408`, `429`, any `5xx` and network errors with backoff, sending the byte-identical body with the same `batch_id`. Totallytics deduplicates on `batch_id`, so a retried batch is counted once. Never reuse a `batch_id` for different data: it would be dropped as a duplicate.

## Keys

Keys are created in site settings under **API**, or with these endpoints and the owner's token. Each site can have up to 10 active keys.

### List keys

`GET /api/sites/{hostname}/keys`

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  "https://totallytics.com/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/keys" \
  -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}"
```

```json
{
  "keys": [
    {
      "id": "0b6f3f7e-5d1c-4f5e-9a41-2f8c6d7e9b10",
      "label": "Production",
      "prefix": "tt_3f9a2c71",
      "created_at": "2026-09-26T14:02:11.482Z",
      "last_used_at": "2026-09-28T03:12:40.117Z"
    }
  ]
}
```

Only active keys are listed, newest first. `prefix` is the first 11 characters of the key, so you can tell keys apart; the full key is never returned again. `last_used_at` stays `null` until a batch sent with the key is stored, and then updates at most once a minute.

### Create a key

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

The body is optional. `label` is trimmed and cut to 64 characters.

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -X POST "https://totallytics.com/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/keys" \
  -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}" \
  -H 'Content-Type: application/json' \
  --data '{"label":"Production"}'
```

```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://totallytics.com/api/sites/${encodeURIComponent(hostname)}/keys`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ label: "Production" }),
    signal: AbortSignal.timeout(20_000),
  },
);
if (!response.ok) {
  throw new Error(`Create key ${response.status}: ${await response.text()}`);
}
console.log(await response.json());
```

Success is `201`. `secret` is the full key and is only returned here, so store it right away.

```json
{
  "key": {
    "id": "0b6f3f7e-5d1c-4f5e-9a41-2f8c6d7e9b10",
    "label": "Production",
    "prefix": "tt_3f9a2c71",
    "created_at": "2026-09-28T09:30:00.000Z",
    "last_used_at": null
  },
  "secret": "tt_3f9a2c71d04e5b6a8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5"
}
```

| Status | Message                                                     | Meaning                                                  |
| ------ | ----------------------------------------------------------- | -------------------------------------------------------- |
| 400    | `invalid request body`                                      | The body is JSON but not an object                       |
| 400    | `label must be text`                                        | `label` is present but not a string                      |
| 400    | `A site can have up to 10 active keys. Revoke one first.`   | The site already has 10 active keys                      |
| 403    | `Install your tracking script to verify this site first`    | The site is a website that is not verified yet           |

API sites (`kind` `api`) can create keys right after registration; websites need verification first. See [API-only sites](/docs/sites#api-only-sites).

### Revoke a key

`DELETE /api/sites/{hostname}/keys/{id}`

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  -X DELETE "https://totallytics.com/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/keys/${KEY_ID:?Set KEY_ID}" \
  -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}"
```

Success is `200` with `{ "revoked": "<id>" }`. Ingest rejects the key within a minute. An id that does not exist on this site, or is already revoked, returns `404` `unknown key`.

## Request stats

All four endpoints are owner-only and take the same parameters. They only count requests timestamped after the site's `created_at`.

| Parameter | Default         | Description                                                                          |
| --------- | --------------- | ------------------------------------------------------------------------------------ |
| `from`    | `to` - 30 days  | Range start, Unix seconds                                                            |
| `to`      | now             | Range end, Unix seconds; must be after `from`, and the range at most 400 days        |
| `tz`      | `UTC`           | IANA timezone for series buckets; unknown values fall back to `UTC`                  |
| `f`       | none            | Filter, repeatable; see [Filters](#filters)                                          |
| `limit`   | per endpoint    | Maximum rows                                                                         |

Latency fields are milliseconds, rounded to whole numbers from 100 ms and to one decimal below, and `null` when there are no requests. Range edges more than 34 days back round to whole hours, because only hourly rollups are kept that long.

### Overview

`GET /api/sites/{hostname}/requests/overview`

```curl
curl --fail-with-body --silent --show-error --max-time 20 \
  "https://totallytics.com/api/sites/${EA_HOSTNAME:?Set EA_HOSTNAME}/requests/overview?tz=Europe/Amsterdam" \
  -H "Authorization: Bearer ${TT_TOKEN:?Copy a token from Authentication}"
```

```json
{
  "totals": {
    "requests": 48210,
    "client_errors": 1204,
    "server_errors": 96,
    "avg_ms": 38.4,
    "p50_ms": 21.6,
    "p95_ms": 142,
    "p99_ms": 388
  },
  "previous": {
    "requests": 45877,
    "client_errors": 1310,
    "server_errors": 141,
    "avg_ms": 41.2,
    "p50_ms": 22.3,
    "p95_ms": 157,
    "p99_ms": 420
  },
  "series": [
    {
      "t": 1790460000,
      "s2xx": 1980,
      "s3xx": 12,
      "s4xx": 51,
      "s5xx": 4,
      "p50_ms": 21.1,
      "p95_ms": 139
    }
  ],
  "granularity": "day",
  "has_data": true
}
```

| Field           | Meaning                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------- |
| `totals`        | Requests, 4xx (`client_errors`), 5xx (`server_errors`), average and percentile latency   |
| `previous`      | The same totals for the period of equal length right before `from`                       |
| `series`        | One point per bucket, with requests per status class and p50/p95; empty buckets are zero |
| `granularity`   | `hour` for ranges up to 4 days, otherwise `day`, in `tz`; `t` is the bucket start in Unix seconds |
| `has_data`      | Whether the site has received any API requests since registration, in any range          |

### Breakdown

`GET /api/sites/{hostname}/requests/breakdown?dim=endpoints`

`dim` is required. `limit` defaults to 100, at most 500. Rows are sorted by requests, highest first.

| `dim`       | `name`                                                                  |
| ----------- | ----------------------------------------------------------------------- |
| `endpoints` | `METHOD route`, with separate `method` and `route` fields               |
| `statuses`  | Status code as a string, such as `"404"`                                |
| `clients`   | Client name, such as `curl`; `unknown` without a `User-Agent`           |
| `consumers` | Consumer id; empty for requests without one                             |

```json
{
  "rows": [
    {
      "name": "GET /users/:id",
      "method": "GET",
      "route": "/users/:id",
      "requests": 20412,
      "client_errors": 311,
      "server_errors": 12,
      "p50_ms": 18.9,
      "p95_ms": 121,
      "p99_ms": 344
    }
  ]
}
```

### Errors

`GET /api/sites/{hostname}/requests/errors`

One row per status, method and route with at least one 4xx or 5xx response. 5xx rows come first, then the most frequent. `limit` defaults to 100, at most 500.

```json
{
  "rows": [
    {
      "status": 500,
      "method": "POST",
      "route": "/v1/uploads",
      "requests": 42,
      "endpoint_requests": 1985,
      "samples": 17,
      "last_seen": 1790562310
    }
  ]
}
```

`requests` counts the failed requests, and `endpoint_requests` all requests to that method and route in the range, so the error rate is `requests / endpoint_requests`. A `status` filter narrows the rows but not `endpoint_requests`. `samples` is the number of stored samples in the range, and `last_seen` the Unix seconds of the newest one, or `null` without samples. Samples are kept for 14 days.

### Samples

`GET /api/sites/{hostname}/requests/samples?f=status:500&f=endpoint:POST%20/v1/uploads`

The latest individual 4xx and 5xx requests, newest first. `limit` defaults to 20, at most 100. Filter by `status` and `endpoint` to get the samples behind an error row.

```json
{
  "rows": [
    {
      "ts": 1790562310482,
      "method": "POST",
      "route": "/v1/uploads",
      "path": "/v1/uploads",
      "status": 500,
      "duration_ms": 812.4,
      "client": "python-requests",
      "client_version": "2.32",
      "consumer": "acct_4821",
      "message": "upstream timeout"
    }
  ]
}
```

`ts` is Unix milliseconds. `duration_ms` is rounded to 0.1 ms. `message` is empty unless the sender attached one.

## Filters

Every request stats endpoint accepts repeated `f=<key>:<value>` parameters. Everything after the first colon is the value, so `f=endpoint:GET /users/:id` works; URL-encode the space.

| Key        | Value                                      | Example                        |
| ---------- | ------------------------------------------ | ------------------------------ |
| `endpoint` | `METHOD route`, as in the breakdown `name` | `f=endpoint:GET%20/users/:id`  |
| `status`   | Status code                                | `f=status:500`                 |
| `client`   | Client name                                | `f=client:curl`                |
| `consumer` | Consumer id; empty for requests without one | `f=consumer:`                 |

Values of the same key match any of them; different keys must all match. Up to 12 filters, each value up to 256 characters.

## Errors

Errors use `{ "error": "message" }`.

| Status | Message                                                  | Meaning                                                    |
| ------ | -------------------------------------------------------- | ---------------------------------------------------------- |
| 400    | `invalid range`                                          | `from` or `to` is empty or not a number, `from` is not before `to`, or the range exceeds 400 days |
| 400    | `invalid filter`                                         | Unknown key, empty value (except `consumer`), too many filters or too long |
| 400    | `dim must be endpoints, statuses, clients or consumers`  | Missing or unknown breakdown `dim`                         |
| 401    | `sign in required`                                       | Owner token missing, invalid or expired                    |
| 404    | `unknown site`                                           | Hostname not registered to your account                    |
| 404    | `unknown key`                                            | Key id not found on this site, or already revoked          |
| 404    | `not found`                                              | Unknown route or method under `keys` or `requests`         |
| 500    | `internal error`                                         | Unexpected server error; retry later                       |
