API reference

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.

Base URL: https://totallytics.com. 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
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. 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
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
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 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.

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