Clicktag docs

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.

Base URL: https://clicktag.io. Download OpenAPI or read this page as Markdown.

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, 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. 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 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 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 ${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
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
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.

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
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