GRANSKA

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.

StatusCodeMeaning
400BAD_REQUESTThe request is missing a required field, or a field failed validation.
400HEALTH_CHECK_FAILEDThe health endpoint was asked to fail on purpose.
400UNSUPPORTED_GRANT_TYPEThe token request named a grant type other than client_credentials.
401UNAUTHORIZEDNo credentials, unreadable credentials, or credentials that do not match a tenant.
401TOKEN_EXPIREDThe access token was valid and has since expired. Request a new one.
403FORBIDDENThe credentials are valid but the tenant is not licensed for what was asked.
404NOT_FOUNDNo such resource, or none this tenant may see. The two are deliberately indistinguishable.
409CONFLICTThe resource is in a state that forbids the operation.
409STALE_AGREEMENT_VERSIONuploadAttestation names an earlier version of the agreement. details.currentVersion is the one to read and attest instead.
409UPLOAD_NOT_ADMITTEDThe 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.
409HISTORY_UNAVAILABLEThe period asked for is older than the history the service still holds. details.earliestCompleteMonth names the earliest month it can answer.
429TOO_MANY_REQUESTSThe tenant's run quota for the current period is spent, or — on a configuration write — its hourly write floor is.
500INTERNAL_ERRORAn unexpected server-side failure. Retry once at 500; at 4xx the request itself is malformed and retrying will not help.
503SERVICE_UNAVAILABLEA 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. On POST /v1/analyze and POST /v1/action it is your run allowance for the period, and waiting is the only thing that helps. On POST /v1/profiles, PATCH /v1/profiles/:id and GET /v1/laws/:jurisdiction/:work it 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 a GET and 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 cases X-RateLimit-Reset says when the counter turns over, and GET /v1/quotas reports 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 no uploadAttestation; error.details.missing says which. Only an administrator of your organisation can accept the agreement, signed in to the GRANSKA app. See GET /v1/legal-agreement.
  • 503 SERVICE_UNAVAILABLE — transient, and the only failure that tells you when to come back. Honour the Retry-After header.
  • 5xx otherwise — 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-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset
Response
{
  "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
  }
}