Set your key once:
export TRUESTAT_API_KEY="ts_live_a1b2c3d4e5f6..."Find your site ids
curl "https://truestat.io/api/v1/sites" \
-H "Authorization: Bearer $TRUESTAT_API_KEY"{
"data": [
{
"id": "9c2f4e81-6d3a-4b17-9f52-8a1e0c7d3b46",
"name": "Docs",
"domain": "example.com",
"timezone": "Europe/Berlin",
"active": true,
"created_at": "2026-07-14T09:22:41.000Z",
"first_event_at": "2026-07-14T10:06:18.000Z"
}
],
"meta": {
"page": { "limit": 100, "offset": 0, "has_more": false }
}
}first_event_at is null until the first event arrives. That is how you tell
a site that was created and never installed from one that is simply quiet
today.
There is no site_id in meta here — this answer is not about one site. Every
other endpoint below echoes the site it answered for.
One number for a status page
curl "https://truestat.io/api/v1/sites/9c2f4e81-6d3a-4b17-9f52-8a1e0c7d3b46/overview?period=last30d&tz=Europe/Berlin" \
-H "Authorization: Bearer $TRUESTAT_API_KEY"{
"data": {
"visitors": 3912,
"new_visitors": 2874,
"returning_visitors": 1038,
"sessions": 5106,
"views": 12483,
"bounce_rate": 54.3,
"avg_session_seconds": 212,
"goals": 184,
"goal_value_cents": 0,
"payments": 47,
"revenue_cents": 418300,
"revenue_per_visitor_cents": 107,
"conversion_rate": 1.2
},
"meta": {
"site_id": "9c2f4e81-6d3a-4b17-9f52-8a1e0c7d3b46",
"range": {
"from": "2026-07-25T22:00:00.000Z",
"to": "2026-08-24T22:00:00.000Z",
"clamped": false,
"tz": "Europe/Berlin"
}
}
}Thirteen fields, always all of them — an empty window answers with zeroes
rather than null, so there is no branch to write for "nothing happened".
Two things to read carefully:
meta.range.fromandtoare UTC, whatevertzyou passed.tzdecides where the day boundaries fall, not how the instants are printed. The window above starts at midnight in Berlin, which is22:00Zthe previous day.Money is in cents, as integers, and
goal_value_centsis not part ofrevenue_cents. Values you attach to server-sent goals are yours to define; revenue comes from the payment provider. Adding them puts money on the dashboard your Stripe account has never seen.
bounce_rate and conversion_rate are percentages, not fractions: 54.3 means
54.3%.
Cache this. A status page does not need to ask on every page load.
Chart the last 7 days
curl "https://truestat.io/api/v1/sites/$SITE/timeseries?period=last7d&unit=day&tz=Europe/Berlin" \
-H "Authorization: Bearer $TRUESTAT_API_KEY"{
"data": [
{ "bucket": "2026-08-18 00:00:00", "visitors": 511, "new_visitors": 388, "returning_visitors": 123, "views": 1642, "goals": 27, "goal_value_cents": 0, "crawler_views": 903 },
{ "bucket": "2026-08-19 00:00:00", "visitors": 604, "new_visitors": 447, "returning_visitors": 157, "views": 1893, "goals": 31, "goal_value_cents": 0, "crawler_views": 1147 },
{ "bucket": "2026-08-20 00:00:00", "visitors": 672, "new_visitors": 502, "returning_visitors": 170, "views": 2114, "goals": 38, "goal_value_cents": 0, "crawler_views": 864 },
{ "bucket": "2026-08-21 00:00:00", "visitors": 559, "new_visitors": 401, "returning_visitors": 158, "views": 1776, "goals": 24, "goal_value_cents": 0, "crawler_views": 1298 },
{ "bucket": "2026-08-22 00:00:00", "visitors": 447, "new_visitors": 316, "returning_visitors": 131, "views": 1408, "goals": 19, "goal_value_cents": 0, "crawler_views": 772 },
{ "bucket": "2026-08-23 00:00:00", "visitors": 208, "new_visitors": 142, "returning_visitors": 66, "views": 617, "goals": 6, "goal_value_cents": 0, "crawler_views": 691 },
{ "bucket": "2026-08-24 00:00:00", "visitors": 173, "new_visitors": 119, "returning_visitors": 54, "views": 502, "goals": 4, "goal_value_cents": 0, "crawler_views": 583 }
],
"meta": {
"site_id": "9c2f4e81-…",
"range": { "from": "…", "to": "…", "clamped": false, "tz": "Europe/Berlin" }
}
}Empty buckets come back as zeros, not missing. A quiet day is a 0 row, so
your chart's time axis stays honest without you filling gaps.
crawler_views is there whatever bots says. bots=excluded removes crawler
traffic from the human figures; it does not mean "hide the crawler line".
It is the total — the split by purpose is on
crawler series.
Note that visitors do not sum to the 30-day figure above — someone visiting on three days is one visitor in the month and three in the daily sum.
Top pages, filtered to one section
The dimension is a path segment, and it is path — not pages. A name that is
not on the list is a 400 that names the ones that are.
curl "https://truestat.io/api/v1/sites/$SITE/breakdowns/path?period=last30d&filter_path_pattern=/docs/*&limit=5" \
-H "Authorization: Bearer $TRUESTAT_API_KEY"{
"data": [
{ "label": "/docs/getting-started", "visitors": 1180, "views": 2914 },
{ "label": "/docs/install/nextjs", "visitors": 703, "views": 1287 },
{ "label": "/docs/api", "visitors": 512, "views": 897 },
{ "label": "/docs/script/options", "visitors": 389, "views": 664 },
{ "label": "/docs/privacy", "visitors": 244, "views": 421 }
],
"meta": {
"site_id": "9c2f4e81-…",
"range": { "…": "…" },
"page": { "limit": 5, "offset": 0, "has_more": true }
}
}The row's key is label, not path. It is deliberately not named after the
dimension: swap path for country or referrer in the URL and the same code
reads the result. Some dimensions add fields on top — country_code on
countries, clicks on exit links and downloads, goals and revenue_cents on
goals — and a dimension that means nothing by a field simply omits it rather
than sending a null you have to filter.
meta.page.has_more answers "is there a sixth row", measured rather than
guessed: the query asks for one more row than you did and reports whether it
came back. There is no total, because counting every matching row costs as
much as the query itself and is stale by the time you read it.
Which AI crawlers read your site
curl "https://truestat.io/api/v1/sites/$SITE/ai/crawlers?period=last30d" \
-H "Authorization: Bearer $TRUESTAT_API_KEY"{
"data": [
{ "operator": "gpt", "purpose": "ai_training", "views": 1864, "verified_views": 1851, "spoofed_views": 13 },
{ "operator": "claude", "purpose": "ai_training", "views": 1207, "verified_views": 1207, "spoofed_views": 0 },
{ "operator": "gpt", "purpose": "ai_assistant", "views": 612, "verified_views": 608, "spoofed_views": 4 },
{ "operator": "google", "purpose": "search", "views": 488, "verified_views": 471, "spoofed_views": 0 },
{ "operator": "perplexity", "purpose": "ai_assistant", "views": 174, "verified_views": 0, "spoofed_views": 0 }
],
"meta": {
"site_id": "9c2f4e81-…",
"range": { "…": "…" },
"page": { "limit": 100, "offset": 0, "has_more": false }
}
}One operator appears once per purpose, so gpt is two rows above. Sum them for
an operator's total, or call
traffic split, which groups for you.
Every view is checked by reverse DNS, and the check sorts them into three groups:
| What it means | |
verified_views | The round trip came back to the operator's own network. This really was that crawler. |
spoofed_views | It came back to somebody else. Something wore the name and did not own it. |
| the rest | The operator publishes nothing to check against, so nobody can verify it — us included. |
The third group has no field. Subtract the two from views to get it. The
Perplexity row above is entirely that: 174 views, nothing proved either way,
because there is nothing published to check them against. A zero in
verified_views is not a red flag on its own — read it next to
spoofed_views, which is the number to act on.
See How reliable is it, which also explains why these counts are a floor rather than a total.
Only the crawlers answering live questions
curl "https://truestat.io/api/v1/sites/$SITE/ai/pages?period=last30d&filter_bot_kind=ai_assistant" \
-H "Authorization: Bearer $TRUESTAT_API_KEY"{
"data": [
{ "path": "/docs/api", "purpose": "ai_assistant", "views": 284 },
{ "path": "/docs/getting-started", "purpose": "ai_assistant", "views": 197 },
{ "path": "/llms.txt", "purpose": "ai_assistant", "views": 91 }
],
"meta": {
"site_id": "9c2f4e81-…",
"range": { "…": "…" },
"page": { "limit": 100, "offset": 0, "has_more": false }
}
}purpose travels with the page, so without that filter the same path appears
once per reason it was fetched — /docs/api under ai_assistant and again
under ai_training are two rows, and it usually matters which one grew.
ai_assistant means someone asked a question and your page was fetched to
answer it. ai_training means corpus collection with no reader at the other
end. Two different findings — see
What this panel shows.
Your usage this period
curl "https://truestat.io/api/v1/usage" \
-H "Authorization: Bearer $TRUESTAT_API_KEY"{
"data": {
"period_start": "2026-08-04T00:00:00.000Z",
"period_end": "2026-09-04T00:00:00.000Z",
"events": {
"used": 147382,
"limit": 200000,
"rejected": 0,
"over": false
},
"crawler_events": {
"used": 38914,
"limit": 100000,
"over": false
},
"limits": {
"max_sites": 25,
"max_team_seats": 10,
"max_alerts": 20,
"retention_days": 1095
}
},
"meta": {}
}Two meters, and they are never added together. events counts what people
did, crawler_events what machines did, and they have opposite consequences:
running out of events closes reading until the plan grows, while running out
of crawler_events pauses crawler collection and closes no screen. Summing them
undoes the reason they are separate.
Collection does not stop when events.over is true — your events keep
landing, and rejected says how many were produced but not processed. What
pauses is reading the period: this endpoint and
account keep answering, and everything else
returns 402 quota_exhausted. See
When you reach your event limit.
A null limit means unlimited, not zero and not unknown. And data itself
is null when there is no live subscription — an account without one has no
plan, so it has no quota either, and answering "0 of 10,000 used" would invent
one. Check for that before reading into it.
A small Python client
import json
import os
import urllib.error
import urllib.parse
import urllib.request
BASE = "https://truestat.io/api/v1"
def truestat(path, **params):
url = BASE + path
if params:
url += "?" + urllib.parse.urlencode(params)
request = urllib.request.Request(
url,
headers={"Authorization": "Bearer " + os.environ["TRUESTAT_API_KEY"]},
)
try:
with urllib.request.urlopen(request) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = json.load(error)["error"]
raise RuntimeError(f"{body['code']}: {body['message']}") from None
result = truestat(
f"/sites/{os.environ['SITE_ID']}/overview",
period="last30d",
)
data = result["data"]
print(f"{data['visitors']} visitors, {data['views']} views")
if result["meta"]["range"]["clamped"]:
print("Window was shortened by your plan's retention.")
usage = truestat("/usage")["data"]
if usage is None:
print("No active subscription.")
else:
print(f"{usage['events']['used']} of {usage['events']['limit']} events")The error body is the same { "error": { "code", "message" } } shape on every
refusal, so one handler covers all of them — see Errors.
A small Node client
const BASE = "https://truestat.io/api/v1";
async function truestat(path, params = {}) {
const url = new URL(BASE + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.TRUESTAT_API_KEY}` },
});
const body = await res.json();
if (!res.ok) {
throw new Error(`${body.error.code}: ${body.error.message}`);
}
return body;
}
const { data } = await truestat(
`/sites/${process.env.SITE_ID}/overview`,
{ period: "last30d" }
);
console.log(`${data.visitors} visitors, ${data.views} views`);The key lives in an environment variable and the call is server-side. A key in browser JavaScript is public — see Authentication.