Skip to content

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:

X-Request-ID: ch-node01-01997f2a8b3c7d5e8f01abcdef123456

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.

codeMeaning
invalid_requestThe JSON body is malformed
metrics_requiredmetrics was empty or missing
unknown_metricNo such metric
metric_not_availableReal metric, but this dimension does not compute it
too_many_dimensionsMore than one dimension
unknown_dimensionNo such dimension
invalid_date_rangeMissing, malformed, or outside the allowed span
invalid_intervalThe time bucket does not suit the range
invalid_filterBad filter field, operator, or value
event_filter_requiredevent:props:<key> without an event filter to scope it
invalid_comparecompare is neither previous_period nor a valid pair
compare_length_mismatchThe compare range is a different length from date_range
site_id_requiredAn all-sites key omitted site_id
limit_offset_misuselimit or offset sent on an aggregate or time series
invalid_scopeA requested key permission is not one Statable has
invalid_expiryexpires_in_days outside 1 to 365
key_limit_reachedThe account is at its cap for active keys, ten by default
terms_not_acceptedRegistration without accept_terms: true. No account is created
domain_not_allowedThe domain or email address sits in a zone Statable does not accept
email_undeliverableThe 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

StatuscodeMeaning
401unauthorizedThe token is missing, malformed, invalid, expired, or revoked
401otp_invalidThe 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

StatuscodeMeaning
403insufficient_scopeThe key lacks the permission the endpoint needs
403key_not_scopedA single-site key asked for a different site
403tracking_inactiveThe site is no longer collecting data
403not_site_ownerYour role on the site does not allow it: a Member cannot write at all, and only the owner can delete
403scope_escalationA 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

StatuscodeMeaning
404unknown_siteThe site_id does not exist, or you have no access to it
404unknown_funnelNo such funnel on the resolved site
404goal_not_foundNo such goal on the resolved site
404api_key_not_foundUnknown, already revoked, or another account's key
404write_disabledThe write surface is switched off on this deployment
404not_foundNo /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

StatuscodeMeaning
405method_not_allowedThe path exists, the method does not

The response carries an Allow header listing the methods the path does accept, and repeats them in hint:

Allow: GET, POST
{
  "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.

StatuscodeMeaning
409site_existsThe account already has a site with that URL
409goal_existsThe site already has a goal with that name
409funnel_existsThe site already has a funnel with that name
409goal_in_useA funnel step still uses the goal; the message names the funnel
409idempotency_conflictAn Idempotency-Key was reused with a different body, or its first request is still running
409self_modificationA key tried to rotate or revoke itself
409hobby_always_publicpublic-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

StatuscodeMeaning
429rate_limitedAn 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

StatuscodeMeaning
500internalSomething 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

StatusRetry?
400No. Fix the request first
401No. Get a working key
403No. Use a key that reaches the site
404No, unless you are confirming against GET /sites
405No. Read the Allow header and use one of those methods
409No. The thing you are creating is already there, or the thing you are deleting is still in use
429Yes, after Retry-After
500Yes, 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


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.