Not available yet. The routes are not built. The error codes below are the planned set; the authentication codes are the ones the auth layer already emits.
Every error is one shape:
{
"error": {
"code": "insufficient_scope",
"message": "This key does not have access to that site."
}
}code is a symbolic string, not a repeated HTTP status. Branch on code —
it is stable, and it distinguishes cases that share a status.
message is for humans. It never contains anything from your request — no
field name, no value, no site id. That is deliberate: a public endpoint that
echoes input back is both a way to inject content and a way to probe. Do not
parse it.
Every code
400 — the request is malformed
code | Meaning | Fix |
invalid_request | A parameter is the wrong shape — a bad timezone, an unknown unit, a limit over 1000 | Check it against Endpoint reference |
invalid_range | from without to, or to without from | Send both, or use period instead |
conflicting_range | period sent together with from/to | Use one or the other |
unknown_dimension | A breakdown dimension that does not exist | The valid list is in Endpoint reference |
Retrying without changing the request will not help.
401 — we do not know who you are
code | Meaning |
unauthenticated | No Authorization header |
invalid_credential | The key is wrong, revoked or expired |
invalid_credential covers all three on purpose. Distinguishing "revoked" from
"never existed" would tell someone probing that a key had once been real.
If your key worked yesterday: check whether it was revoked, whether it hit an
expiry date, and whether you are on the right environment — a ts_test_ key
against production is invalid_credential.
403 — we know who you are, and no
code | Meaning | Fix |
insufficient_scope | The key is valid but is not scoped to this site | Check the key's site scope. The API itself is on every plan, so this is a scope problem rather than a plan problem. |
site_not_accessible | The site is not readable by this key | See below |
insufficient_role | The action needs a higher role | Not applicable to the read API today |
404 — no such route
The path does not exist. Note that a site you cannot access returns 403, not
404 — see below.
429 — too many requests
code | Meaning |
rate_limited | You have exceeded your rate limit |
The response carries Retry-After with the seconds to wait. Back off
exponentially; do not retry in a tight loop. See Rate limits.
5xx — our problem
code | Status | Meaning |
temporarily_unavailable | 503 | A transient failure. Retry with backoff. |
internal_error | 500 | Something broke. Retrying may work; if it persists, tell us. |
A 500 that repeats for the same request is worth reporting to
support@truestat.io with the endpoint, the
parameters and roughly when. Do not send your key.
403 vs 404
A site your key cannot reach returns 403, not 404 — and it returns the
same 403 whether the site belongs to someone else or does not exist at all.
That is on purpose. If a nonexistent site gave 404 and someone else's site gave
403, the difference would tell a caller which site ids are real, which turns
the endpoint into a way to enumerate other customers' sites.
So when you get site_not_accessible, the possibilities are: the id is wrong,
the site is in another organisation, your key is scoped to specific sites and
this is not one, or the site was deleted. Call /sites to see what the key can
actually read.
Errors from the collector
/api/events — where your tracking script sends page views — is a different
surface with a different error set. If you are debugging a tag rather than an API
client, see
Verifying it works,
which lists every status the collector returns and what to do about each.
The one worth knowing here: the collector's success status is 202, not
200.
Handling errors well
const res = await fetch(url, { headers });
const body = await res.json();
if (!res.ok) {
switch (body.error.code) {
case "rate_limited":
await sleep(Number(res.headers.get("Retry-After") ?? 60) * 1000);
return retry();
case "invalid_credential":
case "insufficient_scope":
// Configuration, not transient. Do not retry.
throw new Error(body.error.code);
case "temporarily_unavailable":
case "internal_error":
return retryWithBackoff();
default:
// 400-class. The request is wrong; retrying it unchanged will not help.
throw new Error(`${body.error.code}: ${body.error.message}`);
}
}The distinction that matters: 4xx other than 429 means fix the request.
429 and 5xx mean wait and try again. Retrying a 400 in a loop only uses
your rate limit.