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.
One route is the exception, and it is the only one.
POST /v1/mcp speaks the Model Context Protocol, whose errors are JSON-RPC errors:
error.code is a number from the protocol's own number space, not a name from the catalogue
below, and there is no documentation_url. Everything on this page describes the other routes. The
split is only below authentication — a call to that route without a valid token is refused before the
protocol is reached, and answers UNAUTHORIZED in the envelope described here, exactly like anywhere
else. Its own page lists the numbers it can send.
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. |
409 | LEGAL_AGREEMENT_REQUIRED | The document cannot be uploaded until the current upload agreement is met. details.missing names what is missing: ORGANISATION_ACCEPTANCE is given by an administrator of the organisation in the GRANSKA app, never through this API; UPLOAD_ATTESTATION is uploadAttestation on the request. details.agreementVersion is the version to accept and attest, details.agreementUrl where it is read. |
409 | STALE_AGREEMENT_VERSION | uploadAttestation names an earlier version of the agreement. details.currentVersion is the one to read and attest instead. |
409 | UPLOAD_NOT_ADMITTED | The uploaded document was not admitted under the upload agreement when its upload URL was issued, or was admitted to another credential. Upload it again from POST /v1/upload-url; details.reason says why. |
409 | HISTORY_UNAVAILABLE | The period asked for is older than the history the service still holds. details.earliestCompleteMonth names the earliest month it can answer. |
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.409 LEGAL_AGREEMENT_REQUIRED— not something a retry or your code can fix. Your organisation has not accepted the current upload agreement, or the request carried nouploadAttestation;error.details.missingsays which. Only an administrator of your organisation can accept the agreement, signed in to the GRANSKA app. SeeGET /v1/legal-agreement.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
Nine endpoints carry the three headers, and they get them from two different places.
Seven 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 five that write configuration — POST /v1/profiles,
PATCH /v1/profiles/:id, DELETE /v1/profiles/:id, POST /v1/snippets and
PATCH /v1/snippets/:id. The
middleware runs before the handler, so on those seven 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 other two set them themselves. 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.
POST /v1/mcp meters in its handler as well, with one unconditional read that no
refusal can slip past, so every answer that handler produces carries the headers — the 429 and a
tool execution error included. That read comes after the call has run, because a tools/call that
starts an analysis has to be counted before the numbers are true. Two of its answers do not carry
them, and both are named on that page: a 401, refused above
the handler like every other one, and the rare answer sent while the counter itself could not be
read. That one is sent without the headers rather than discarded, because it may already name an
analysis that is queued and charged.
Which counter the headers describe depends on the endpoint you sent. On the analysis endpoints
and on POST /v1/mcp they report the run allowance for the period; on the other six they report
the hourly configuration-write floor. The header names are the same on all nine, 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 nine metered calls.
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
{
"limits": {
"maxRunsPerPeriod": 500,
"periodType": "MONTH",
"maxWorkersPerProfile": 3
},
"usage": {
"usedRuns": 13,
"remainingRuns": 487,
"resetAt": 1786838400,
"periodKey": "MONTH_2026-8"
},
"profiles": {
"stored": 6,
"max": 50
},
"snippets": {
"stored": 34,
"max": 400
},
"actions": {
"stored": 12,
"max": 200
},
"configWrites": {
"used": 2,
"max": 60,
"remaining": 58,
"resetAt": 1786838400
}
}