Errors
Every failure comes back in the same shape, with an HTTP status and two fields:
{
"code": "unknown_metric",
"error": "unknown metric: foo",
"request_id": "ch-node01-01997f2a8b3c7d5e8f01abcdef123456"
}
not_found and method_not_allowed carry two more fields, hint and docs: a sentence naming the likely mistake, and the address of the OpenAPI spec. No other error has them, so treat both as optional everywhere.
Branch on code. It is part of the contract and will not change. error is a human-readable detail meant for logs and error messages, and its wording can be reworded at any time. A client that matches on error text will break; one that matches on code will not.
Request id
Every response carries an X-Request-ID header, and error bodies repeat the same value as request_id:
This is the one value worth capturing. The leading segment names the machine that served the call, so the id on its own is enough to find the log line. Without it, a 500 can only be matched by guessing at a time window, which often means it cannot be found at all.
It is present on successful responses too, so logging it beside your own records helps just as much when the numbers look wrong as when a call outright fails.
You may send your own X-Request-ID. It is recorded alongside ours so a trace can be followed across both systems, but the value echoed back is always ours.
Every /api/v1 response carries one, including a 404 for a path that does not exist: that answer is generated by the API too, not by the web server in front of it.
Bad request
400 means the request itself is wrong. Nothing was run, and repeating it unchanged will fail identically.
code | Meaning |
|---|---|
invalid_request | The JSON body is malformed |
metrics_required | metrics was empty or missing |
unknown_metric | No such metric |
metric_not_available | Real metric, but this dimension does not compute it |
too_many_dimensions | More than one dimension |
unknown_dimension | No such dimension |
invalid_date_range | Missing, malformed, or outside the allowed span |
invalid_interval | The time bucket does not suit the range |
invalid_filter | Bad filter field, operator, or value |
event_filter_required | event:props:<key> without an event filter to scope it |
invalid_compare | compare is neither previous_period nor a valid pair |
compare_length_mismatch | The compare range is a different length from date_range |
site_id_required | An all-sites key omitted site_id |
limit_offset_misuse | limit or offset sent on an aggregate or time series |
invalid_scope | A requested key permission is not one Statable has |
invalid_expiry | expires_in_days outside 1 to 365 |
key_limit_reached | The account is at its cap for active keys, ten by default |
terms_not_accepted | Registration without accept_terms: true. No account is created |
domain_not_allowed | The domain or email address sits in a zone Statable does not accept |
email_undeliverable | The address cannot receive mail: a reserved domain, a special-use TLD, or a non-ASCII mailbox |
domain_not_allowed covers both halves of the same rule: a site URL and the email address an account is opened on. The zones are .ru, .su, .by, .рф and .бел, with their punycode forms treated as the same zone. The error field names them, because the one thing you can act on is knowing it was the domain and not your spelling.
unknown_metric and metric_not_available look alike and are not. The first means the name does not exist anywhere. The second means the name is fine but the dimension you paired it with cannot produce it, which is the more common mistake. See the dimension table in Query reference.
Authentication
| Status | code | Meaning |
|---|---|---|
401 | unauthorized | The token is missing, malformed, invalid, expired, or revoked |
401 | otp_invalid | The emailed code is wrong, expired, or already used |
All five causes answer the same way on purpose. A response cannot tell an attacker whether a token was ever real. If your own key stops working, check it under Settings → API keys: an expired key still appears in the table, marked Expired, while a revoked one is gone.
Refused
| Status | code | Meaning |
|---|---|---|
403 | insufficient_scope | The key lacks the permission the endpoint needs |
403 | key_not_scoped | A single-site key asked for a different site |
403 | tracking_inactive | The site is no longer collecting data |
403 | not_site_owner | Your role on the site does not allow it: a Member cannot write at all, and only the owner can delete |
403 | scope_escalation | A new key was asked for permissions the acting key does not hold |
insufficient_scope names the missing permission in the error field. Permissions are fixed when a key is created, so the fix is a new key rather than a changed one. See Permissions.
key_not_scoped is not a permissions problem you can fix by request. Access is fixed when a key is created, so reach a second site with a second key, or an all-sites key.
Not found
| Status | code | Meaning |
|---|---|---|
404 | unknown_site | The site_id does not exist, or you have no access to it |
404 | unknown_funnel | No such funnel on the resolved site |
404 | goal_not_found | No such goal on the resolved site |
404 | api_key_not_found | Unknown, already revoked, or another account's key |
404 | write_disabled | The write surface is switched off on this deployment |
404 | not_found | No /api/v1 route matches that path at all |
The unknown_site overlap is deliberate. A site you cannot read is indistinguishable from one that was never created, same status and same code, so a key cannot be used to sweep for valid ids and learn which accounts exist.
The practical consequence: a 404 does not mean the site is gone. Call GET /sites to see what the key actually reaches. That is the authoritative list, and it is usually the answer.
Wrong method
| Status | code | Meaning |
|---|---|---|
405 | method_not_allowed | The path exists, the method does not |
The response carries an Allow header listing the methods the path does accept, and repeats them in hint:
{
"code": "method_not_allowed",
"error": "DELETE is not allowed for /api/v1/sites",
"hint": "Use one of: GET, POST.",
"docs": "https://statable.com/api/v1/openapi.yaml",
"request_id": "ch-node01-01a0819e9ed27da7a66955f1b802c444"
}
Read Allow rather than the hint text: the header is the machine-readable half and the wording of hint can change.
Conflict
409 means the request was understood and refused because it collides with something that already exists.
| Status | code | Meaning |
|---|---|---|
409 | site_exists | The account already has a site with that URL |
409 | goal_exists | The site already has a goal with that name |
409 | funnel_exists | The site already has a funnel with that name |
409 | goal_in_use | A funnel step still uses the goal; the message names the funnel |
409 | idempotency_conflict | An Idempotency-Key was reused with a different body, or its first request is still running |
409 | self_modification | A key tried to rotate or revoke itself |
409 | hobby_always_public | public-dashboard was set to false on a hobby site whose account has no paid subscription |
site_exists is where the API and the dashboard part company. The dashboard hands back the site you already had; the API refuses, because for software a duplicate is nearly always a retry that lost its answer, and two sites counting the same traffic is worse than an error.
Too many requests
| Status | code | Meaning |
|---|---|---|
429 | rate_limited | An hourly budget ran out |
The error field says which budget ran out: write, account or api key. Retry-After says how many seconds to wait. See Rate limits.
Server error
| Status | code | Meaning |
|---|---|---|
500 | internal | Something failed on our side |
Nothing about your request needs changing. Retry with backoff, and if it persists, check Service status. When you report one, quote the request_id from the response.
What to retry
| Status | Retry? |
|---|---|
400 | No. Fix the request first |
401 | No. Get a working key |
403 | No. Use a key that reaches the site |
404 | No, unless you are confirming against GET /sites |
405 | No. Read the Allow header and use one of those methods |
409 | No. The thing you are creating is already there, or the thing you are deleting is still in use |
429 | Yes, after Retry-After |
500 | Yes, with backoff |
Only 429 and 500 are worth an automatic retry. Retrying the rest costs you rate-limit budget and returns the same answer.
A 409 idempotency_conflict is the one exception worth a second look. If it came back because the first request is still running, waiting and re-sending the same body will eventually replay its result. If it came back because the body changed, retrying will never work: pick a new key or fix the caller.
Next steps
- Query reference: the request shapes most
400s come from - Authentication: what a working key looks like
Ready to take control of your web analytics? Try Statable free for 30 days. No credit card required, full feature access, built for GDPR. Start your free trial or view a live demo.