Errors
Every failure answers with the same JSON envelope and an HTTP status. Match on code. The message
is prose written for a human reading a log, and it changes without notice; the code does not.
The envelope
One object, one key. details is present only on failures that have something structured to add — a
validation report, for instance — and a client should treat it as optional.
The profile write routes are the ones that use it most: a refusal from
POST /v1/profiles or
PATCH /v1/profiles/:id carries details.refusal, a stable name for
which check refused, and details.values, what that check found. Those two pages list every name
they can send. message is never the thing to branch on — it is prose, we improve it, and
several different refusals answer with the same code and status.
documentation_url points at the row for code in the catalogue below. It is on every failure and
is a convenience for whoever is reading the log, not information your client needs in order to act —
branch on code and the status, never on this. It is derived from code, so it carries nothing the
code did not already tell you.
Nothing else is guaranteed. In particular, an error response is not the place to look for the state
of a job: a 404 from GET /v1/jobs/:jobId says the job is not there, and says nothing about
whether it ever was.
{
"error": {
"code": "BAD_REQUEST",
"message": "Missing client_id or client_secret",
"documentation_url": "https://www.granska.cloud/docs/api/errors#BAD_REQUEST"
}
}
The codes
Every code the gateway can emit, and the status a route answers it with.
| Status | Code | Meaning |
|---|---|---|
400 | BAD_REQUEST | The request is missing a required field, or a field failed validation. |
400 | HEALTH_CHECK_FAILED | The health endpoint was asked to fail on purpose. |
400 | UNSUPPORTED_GRANT_TYPE | The token request named a grant type other than client_credentials. |
401 | UNAUTHORIZED | No credentials, unreadable credentials, or credentials that do not match a tenant. |
401 | TOKEN_EXPIRED | The access token was valid and has since expired. Request a new one. |
403 | FORBIDDEN | The credentials are valid but the tenant is not licensed for what was asked. |
404 | NOT_FOUND | No such resource, or none this tenant may see. The two are deliberately indistinguishable. |
409 | CONFLICT | The resource is in a state that forbids the operation. |
429 | TOO_MANY_REQUESTS | The tenant's run quota for the current period is spent, or — on a configuration write — its hourly write floor is. |
500 | INTERNAL_ERROR | An unexpected server-side failure. Retry once at 500; at 4xx the request itself is malformed and retrying will not help. |
503 | SERVICE_UNAVAILABLE | A dependency is temporarily unavailable. Retry after the Retry-After header. |
Two of these carry more meaning than their status suggests.
404 NOT_FOUND does not distinguish "no such thing" from "not yours". A job, a legal source or a
profile belonging to another tenant answers exactly as one that never existed. This is deliberate:
the alternative lets a caller map out what other tenants hold by watching which ids come back 403.
A path this API does not serve at all answers the same way — in this envelope, with this code — so a
misspelled route or a verb an endpoint does not take is something your client can read rather than a
parse failure. Without a credential it is a 401 first, whether or not the path exists.
INTERNAL_ERROR is not always a 500. The fallback handler reuses whatever status the
underlying failure carried, so a malformed JSON body surfaces as 400 INTERNAL_ERROR and an
oversized one as 413 INTERNAL_ERROR. Decide whether to retry from the status, not from the
code.
Retrying
4xx— the request is wrong. Retrying it unchanged produces the same answer, and on an analysis endpoint costs another run every time.429 TOO_MANY_REQUESTS— two different limits answer with this, and they mean different things. OnPOST /v1/analyzeandPOST /v1/actionit is your run allowance for the period, and waiting is the only thing that helps. OnPOST /v1/profiles,PATCH /v1/profiles/:idandGET /v1/laws/:jurisdiction/:workit is the hourly floor on configuration writes, which is an abuse guard rather than a plan limit — reaching it almost always means something of yours is looping, and no analysis quota was spent. The law route is aGETand still belongs there, because a law the library does not hold is fetched, parsed and stored to answer you; it ticks the floor only when that actually happens, so reading a law we already hold never brings you closer to this. In both casesX-RateLimit-Resetsays when the counter turns over, andGET /v1/quotasreports both without spending anything.503 SERVICE_UNAVAILABLE— transient, and the only failure that tells you when to come back. Honour theRetry-Afterheader.5xxotherwise — one retry, then treat it as a fault worth reporting.
Where the rate-limit headers appear
Five endpoints carry the three headers, and they get them from two different places.
Four of them from the metering middleware, which is mounted on every metered endpoint and returns
immediately for everything else: the two that spend a run — POST /v1/analyze and POST /v1/action
— and the two that write configuration — POST /v1/profiles and PATCH /v1/profiles/:id. The
middleware runs before the handler, so on those four the headers appear on every response,
including a 400 that never reached the handler and including the 429 itself. That is the useful
half of the surprise: a rejected request still tells you what it cost.
The fifth sets them itself. GET /v1/laws/:jurisdiction/:work meters
conditionally — only a call that actually fetches a law ticks the floor — so no middleware can decide
it in advance. It reports the floor on both answers, the free one included, which means you can read
where you stand without spending anything to find out. The difference worth knowing is at the front
of the request: a call it refuses before it gets that far, a 400 for a jurisdiction this API does
not carry or an authentication failure, carries no headers at all. GET /v1/laws — the search — is
not metered and never carries them.
Which counter the headers describe depends on the endpoint you sent. On the analysis endpoints they report the run allowance for the period; on the other three they report the hourly configuration-write floor. The header names are the same on all five, so a client that stores them in one place will overwrite one counter's numbers with the other's.
The unhelpful half is that they never appear on GET /v1/quotas, which is the endpoint an integrator
most expects to find them on. It is not metered, so the middleware does not run for it. Read the
quota from that endpoint's body instead — it reports both counters, resetAt included — and read the
headers only off the five metered calls.
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
{
"limits": {
"maxRunsPerPeriod": 500,
"periodType": "MONTH"
},
"usage": {
"usedRuns": 13,
"remainingRuns": 487,
"resetAt": 1786838400,
"periodKey": "MONTH_2026-8"
},
"profiles": {
"stored": 6,
"max": 50
},
"snippets": {
"stored": 34,
"max": 400
},
"configWrites": {
"used": 2,
"max": 60,
"remaining": 58,
"resetAt": 1786838400
}
}