Skip to main content

Reference

Examples

Worked requests in curl, JavaScript and Python.

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.from and to are UTC, whatever tz you passed. tz decides where the day boundaries fall, not how the instants are printed. The window above starts at midnight in Berlin, which is 22:00Z the previous day.

  • Money is in cents, as integers, and goal_value_cents is not part of revenue_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_viewsThe round trip came back to the operator's own network. This really was that crawler.
spoofed_viewsIt came back to somebody else. Something wore the name and did not own it.
the restThe 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.

Was this page helpful?

Last updated September 17, 2026