# API requests

Clicktag takes API request metrics at `POST /api/ingest` with a site API key: up to 5,000 metric rows and 200 error samples per batch of at most 4 MiB, deduplicated by `batch_id`. For the middleware, see [API analytics](/docs/api-analytics).

Base URL: `https://clicktag.io`. [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                                  |
| POST   | `/api/sites/{hostname}/keys/{id}/rotate`   | Replace a key with a new one (`201`)          |
| 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. Management keys (`tt_mk_...`) work too, with the `read` scope for GET requests and `manage` for the others; see [Management keys](/docs/authentication#management-keys). 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`                                                     |
| `X-Totallytics-Site` | Only with a management key: the hostname of the site the batch is for |

Instead of a site key, you can send `Bearer tt_mk_...` with a [management key](/docs/authentication#management-keys) that has the `ingest` scope, and name the site in `X-Totallytics-Site`. This works for your API sites and verified websites; the body stays the same.

```curl
now=$(date +%s)
curl --fail-with-body --silent --show-error --max-time 20 \
  -X POST https://clicktag.io/api/ingest \
  -H "Authorization: Bearer ${CLICKTAG_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  | Optional route template; falls back to `path`                                      |
| `path`        | string  | Raw path, up to 512 characters; query string and fragment are removed. Required without `route` |
| `status`      | integer | 400-599                                                                            |
| `duration_ms` | number  | Optional duration in milliseconds, capped at 24 hours; missing or negative is 0    |
| `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.

Error rows are samples for debugging: they don't add to request counts and don't create error groups, so count every request in `metrics`, failed ones included.

### 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% above 1 ms; one that lands in bucket `0` reads as `0.5`. Rows without a `histogram` count toward requests and the average duration, but not toward percentiles. 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` |
| 500    | `internal error`                                         | Retry with the identical body                      |
| 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`.

With a management key, ingest can also return `400` `X-Totallytics-Site header required` or `invalid site`, `401` `invalid or revoked management key`, `403` `scope ingest required`, `403` `verify this site before sending API data` for a website that is not verified yet, and `404` `unknown site` for a hostname that is not yours. Fix these instead of retrying.

Retry `408`, `429`, any `5xx` and network errors with backoff, sending the byte-identical body with the same `batch_id`. Clicktag 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://clicktag.io/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",
      "created_with": null
    }
  ]
}
```

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. `created_with` holds the `id` and `label` of the management key that created the key, or `null` when it was created while signed in.

### 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://clicktag.io/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://clicktag.io/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,
    "created_with": 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://clicktag.io/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`.

### Rotate a key

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

Creates a new key with the same label and revokes the old one in the same step. It works on a site with 10 active keys, because the count stays the same.

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

Success is `201` with the new key, its `secret` and the id of the revoked key. The secret is only returned here, and ingest rejects the old key within a minute, so deploy the new one right away.

```json
{
  "key": {
    "id": "7c1e9a52-3b4d-4f6e-8a90-1b2c3d4e5f60",
    "label": "Production",
    "prefix": "tt_9d4e7a10",
    "created_at": "2026-10-04T09:41:12.004Z",
    "last_used_at": null,
    "created_with": null
  },
  "secret": "tt_9d4e7a10c2b3a4f5e6d7c8b9a0f1e2d3c4b5a6f7e8d9c0b1",
  "revoked": "0b6f3f7e-5d1c-4f5e-9a41-2f8c6d7e9b10"
}
```

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 these 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`           | Overview only: IANA timezone for series buckets and the previous period; unknown values fall back to `UTC` |
| `f`       | none            | Filter, repeatable; see [Filters](#filters)                                          |
| `limit`   | per endpoint    | Maximum rows, on breakdown, errors and samples                                       |

Latency fields are milliseconds, rounded to whole numbers from 100 ms and to one decimal below. `avg_ms` is `null` without requests, and percentiles are `null` without requests that carry a `histogram`. 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://clicktag.io/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 same clock times, moved back by as many calendar days as the range covers in `tz` |
| `series`        | One point per bucket, with requests per status class (`s2xx` includes 1xx) and p50/p95; empty buckets have zero counts and `null` latency |
| `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, regardless of range and filters |

### 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`. Both come from metric rows, not from error samples. 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, `100` to `599`                | `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, not a number or out of bounds, `from` is not before `to`, or the range exceeds 400 days |
| 400    | `invalid filter`                                         | Unknown key, empty value (except `consumer`), a status that is not a code from 100 to 599, 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                    |
| 401    | `invalid or revoked management key`                      | The management key is unknown or revoked                   |
| 403    | `scope read required`                                    | A management key without `read` called a GET endpoint      |
| 403    | `scope manage required`                                  | A management key without `manage` created, revoked or rotated a key |
| 403    | `Install your tracking script to verify this site first` | Creating a key on a website that is not verified yet       |
| 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`         |
| 429    | `rate limited, retry in 1s`                              | Too many key changes from one management key; wait the seconds in `Retry-After` |
| 500    | `internal error`                                         | Unexpected server error; retry later                       |
