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.

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.
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_REQUESTStwo 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.
  • 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

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-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset
Response
{
  "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
  }
}