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 |
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
}
]
}
EOFSuccess is 202 with the number of rows queued for storage:
{ "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:
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 --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}"{
"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 --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"}'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.
{
"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 --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 --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}"{
"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 |
{
"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.
{
"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.
{
"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 |