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: an endpoint that echoes
input back is both a way to inject content and a way to probe. Do not parse it.
400 — the request is malformed
code | Meaning |
invalid_query | A query parameter is wrong: an unknown period, a bad timezone, from without to, period together with from/to, a limit over 1000, a minutes outside its range |
unknown_dimension | A breakdown dimension that does not exist. The message lists the valid ones. |
organisation_required | /account and /usage describe one account. A personal login can belong to several, so only an API key can call them. |
invalid_json | The body is not valid JSON |
invalid_body | The body parsed but a field is wrong |
The message names what was wrong and, where there is a fixed set, lists it. 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 somebody probing that a key had once been real.
402 — the credential is fine, the plan is not
This is the status most people do not expect, and it is the accurate one: you are who you say you are and you are allowed to ask, but the plan does not cover the answer.
code | Meaning | What fixes it |
subscription_suspended | The subscription ended, or a payment could not be collected | Renew |
quota_exhausted | This period's events went past the plan's allowance, beyond the grace band | Move up a step |
subscription_required | The tier does not include this feature | Change plan |
subscription_suspended and quota_exhausted both send you to billing and
they are not the same problem. One account has to renew; the other has
outgrown its plan, which is good news. Do not collapse them into one message.
quota_exhausted deserves a paragraph
Collection never stops. Past the ceiling your events keep landing — what pauses is your ability to read the period through the API and the dashboard, which close together.
That is the deliberate trade. A gap in the data would be permanent, silent, and impossible to buy back later; a locked screen is none of those things. So nothing is lost while this lasts, and upgrading opens the period you already collected. At your limit covers what to expect.
There is a 10% grace band: the gate turns on past the ceiling, not at 100%, so an ordinary busy month does not read as a failure.
The API closes exactly what the dashboard closes. Past the ceiling the dashboard shows you billing and nothing else — not your analytics, not your site settings, not even the list of which sites you have. The API mirrors that:
| Past the quota | |
| Analytics, AI, live, search, goals | 402 |
/sites, /sites/{id} | 402 |
/account, /usage | still answer |
The last two are the equivalent of the billing screen staying open. /usage
in particular is the answer to a 402 — it tells you how far past the
allowance you are. Refusing it on the quota would lock the door and take down
the sign explaining why.
403 — we know who you are, and no
code | Meaning |
insufficient_scope | The key does not carry the scope this endpoint needs |
site_not_accessible | The site is not yours, or does not exist |
site_collection_stopped | The site exists and is yours, but collection is switched off |
insufficient_role | The action needs a higher role than this credential's owner has |
owner_only | Owners only |
site_not_accessible covers "not yours" and "does not exist" with the same
message, and a malformed id gets it too. Verified: a real site belonging to
another organisation, a well-formed id nobody owns, and not-a-uuid all
return identical responses — so the API cannot be used to discover which site
ids are real.
404 — no such thing
code | Meaning |
not_found | The named resource does not exist |
403 vs 404
A site you cannot see answers 403, never 404 — and so does a site that genuinely does not exist.
That is the point of it. If "not yours" and "no such site" gave different answers, the difference would be a way to ask whether any given id is real, one guess at a time. 404 is used only for resources you already have access to, where a missing one tells nobody anything.
500 — our fault
code | Meaning |
request_failed | Something went wrong on our side |
write_failed | The write could not be completed |
The detail stays on the server. A stack trace or a database message in a public response is a map of the internals.
Retrying is reasonable, with a delay. If it persists, the status of the underlying store is the first thing we check.
503 — temporarily unable
code | Meaning |
not_configured | Event storage is unavailable |
Distinct from 500 on purpose: this one is expected to clear on its own, so retrying with backoff is the right response rather than an escalation.