Overview
GRANSKA audits long documents — typically investigations and decisions produced by a public authority — for objectivity flaws, and anchors every finding it reports in a binding legal provision. The HTTP API exposes that engine directly: you send a document, you get back structured findings. There is no interface in the loop and nothing to embed.
Everything the engine knows about a document type — which reviewers run, which legal sources they
may cite, which follow-up actions exist — is tenant configuration resolved at request time, not
something the API hardcodes. GET /v1/profiles, GET /v1/actions and GET /v1/config are how you
read what your own tenant is licensed for, and POST /v1/profiles and PATCH /v1/profiles/:id are
how you build and change it — the same server-side rules the application's own administration screen
applies, reached from your own code.
Base URL and versioning
Every endpoint lives below https://api.granska.cloud/v1. The version is in the path, so a breaking
change arrives as /v2 rather than as a header you have to remember to send.
GET /v1/health takes no credentials and is the call to point a monitor at.
curl https://api.granska.cloud/v1/health{
"status": "OK",
"gateway": "B2B"
}The shape of a run
Five calls, of which two are optional.
- Get a token.
POST /v1/oauth/tokenexchanges your client id and secret for a bearer token that lives one hour. See Authentication. - Get the document in. Small documents go inline as base64 on the analysis call. Anything
larger goes through
POST /v1/upload-url, which hands back a job id and a pre-signed URL youPUTthe file straight at — the document never passes through the API itself. - Start the analysis.
POST /v1/analyzenames a profile and a document source, and answers immediately with a job id. The work happens asynchronously; the call does not block. - Collect the result. Either poll
GET /v1/jobs/:jobIduntil it reportsCOMPLETED, or givePOST /v1/analyzeawebhookUrland be told. AwebhookSecretsigns the callback with HMAC-SHA256 so you can verify it came from here. - Act on it, optionally.
POST /v1/actionruns a follow-up action — a drafted appeal, a plain-language summary — over a finished analysis.
curl -X POST https://api.granska.cloud/v1/analyze \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jobId": "job_7d41c9",
"pdfUploaded": true,
"profileId": "lss_utredning",
"webhookUrl": "https://kunden.example/hooks/granska",
"webhookSecret": "whsec_4a1f..."
}'{
"success": true,
"jobId": "job_7d41c9",
"status": "QUEUED"
}The same five calls, as code
A whole run, with nothing in it that is not one of the five steps above. It is deliberately plain: no SDK, no retry policy, no error handling beyond the two terminal states — those are decisions your side should make rather than inherit from an example.
Two things in it are easy to leave out. The PUT goes straight at Google Cloud Storage rather than
at this API, so it carries the file's own content type and none of your credentials. And the DELETE
at the end is not tidiness: it is what makes the result gone the moment you have it, instead of up to
half an hour later when the retention sweep reaches it.
const BASE_URL = "https://api.granska.cloud";
async function runAudit(pdf: Buffer, profileId: string) {
// 1. Trade the client credentials for a bearer token, good for an hour.
const tokenRes = await fetch(`${BASE_URL}/v1/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET
})
});
const { access_token } = await tokenRes.json();
const headers = {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json"
};
// 2. Get the document in. The pre-signed URL points at storage, not at this
// API, so the upload carries no Authorization header of its own.
const uploadRes = await fetch(`${BASE_URL}/v1/upload-url`, { method: "POST", headers });
const { jobId, uploadUrl } = await uploadRes.json();
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/pdf" },
body: pdf
});
// 3. Start the analysis. This spends one run and answers immediately.
await fetch(`${BASE_URL}/v1/analyze`, {
method: "POST",
headers,
body: JSON.stringify({ jobId, profileId })
});
// 4. Poll until the job is terminal. Reading a job is free and does not destroy it.
for (;;) {
await new Promise(resolve => setTimeout(resolve, 5000));
const job = await (await fetch(`${BASE_URL}/v1/jobs/${jobId}`, { headers })).json();
if (job.status === "FAILED") throw new Error(job.errorMessage);
if (job.status !== "COMPLETED") continue;
// 5. Write it down on your side first, then destroy it here rather than
// waiting for the sweep.
await save(job.result.clinicalData);
await fetch(`${BASE_URL}/v1/jobs/${jobId}`, { method: "DELETE", headers });
return job.result.clinicalData;
}
}
Building the profile you analyse against
An analysis profile decides what a document is judged against: which reviewers run, what each one is
told to look for, and which legal provisions each of them holds. You can create one from your own
code with POST /v1/profiles and change it later with
PATCH /v1/profiles/:id. Every credential of your organisation may do
this; there is nothing to enable.
Six things about it are worth knowing before you write the call, in the order you will hit them:
- A profile has one id, and it works on every route. What
POST /v1/profileshands back is whatGET /v1/profileslists, whatPATCH /v1/profiles/:idtakes in its path, and whatPOST /v1/analyzeaccepts. Store that one string. The reviewer ids inside a profile are spelled differently — longer, carrying your organisation's own identifier — and are likewise sent back exactly as you received them. - A profile arrives as a draft. Your organisation's own users do not see it in their list until it is published — but the integration that created it can run it immediately, so nothing blocks you from testing end to end.
- You publish it with an edit, and only when you ask.
PATCH /v1/profiles/:idtakes"status": "PUBLISHED", which is what offers the profile to your organisation's own people, and"DRAFT", which takes it back. An edit that does not mentionstatusnever moves it: a published profile stays published, and the change applies to the next analysis that starts. Editing is the sharper of the two verbs for that reason. - You name the provisions; we do not guess them. Each reviewer carries a list of references, each
one a legal order, a work and a pinpoint —
{ "jurisdiction": "SE", "work": "1993:387", "pinpoint": "par_7§" }. All three fields are required on every reference, and one that is missing is a400naming it. The profile's legal orders are derived from what its reviewers cite and cannot be sent as a field. - A pinpoint is the id a provision is addressed by, not the citation it is printed as.
par_7§, not7 §;kap_6_par_1§, not6 kap. 1 §. They are matched exactly and nothing translates between them — so a citation sent as a pinpoint addresses no provision, and the write is refused naming the reference you wrote rather than stored to resolve to nothing later. Take a pinpoint from a response and send it back unchanged — see below. - A law we do not already hold is refused by name — and getting it is one call. The refusal tells
you which work was missing, and
GET /v1/laws/:jurisdiction/:workfetches, parses and stores it on the spot, so the same reference is accepted on the retry. See below.
curl -X POST https://api.granska.cloud/v1/profiles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "LSS-utredning",
"documentType": "LSS",
"workers": [
{
"name": "Rättslig grund",
"instructions": "Weigh the investigation against the conditions for the measure applied for.",
"rules": [
{ "jurisdiction": "SE", "work": "1993:387", "pinpoint": "par_7§" },
{ "jurisdiction": "SE", "work": "1993:387", "pinpoint": "par_9a§" }
]
}
]
}'{
"success": true,
"profileId": "lss_utredning_k4m2",
"workerIds": [
"tenant_9f3a_worker_rattslig_grund_p8x1"
],
"status": "DRAFT"
}Finding the provisions to cite
Three calls take you from a subject you can describe in words to a reference a profile will accept. Nothing in the sequence requires asking us anything.
- Search the catalogue.
GET /v1/lawsmatches free text against every law's number and title in a jurisdiction. Each hit carries thework, adisplayLabelto put in front of a person, and — the field the route exists for —held, which says whether a citation to that law resolves here.SEandNOresolve today; another is refused by name rather than answered with an empty list, because an empty list would read as no such law. - Read the work's provisions.
GET /v1/laws/:jurisdiction/:workreturns every provision of one law with itspinpointand thelabela lawyer writes. A law we do not hold is fetched from the national source, parsed and stored while you wait, soheld: falsemeans not yet wherever the search response reportsfetchable: true— which both Sweden and Norway do. - Copy the pinpoint into the profile. Send the
pinpointback exactly as it arrived —kap_6_par_2a§, never the6 kap. 2 a §printed beside it. Nothing translates between the two, so a label sent as a pinpoint addresses no provision of that law and the write is refused naming the reference you wrote.
What the sequence costs: almost always nothing. Searching is free, and so is reading a law the library already holds. Only a real ingest — step 2 for a law that was not here — spends one tick of the hourly configuration-write floor, and every request for that law during the next 24 hours is free again — for you, and for anyone else who asks for it. The library is one library: a law is the same published text whoever reads it, so it is held once rather than once per customer. No analysis quota is touched at any point.
GET /v1/snippets answers a different question and is still the way to ask it. With no parameters it
lists the legal sources your own organisation holds; with a snippetKey, or with all three of
jurisdiction, work and pinpoint, it resolves exactly one and answers 404 when nothing is
addressed. Read what you already have there, and find what you do not with GET /v1/laws.
curl -G https://api.granska.cloud/v1/laws \
--data-urlencode "jurisdiction=SE" \
--data-urlencode "query=föräldrabalk" \
-H "Authorization: Bearer $TOKEN"{
"jurisdiction": "SE",
"fetchable": true,
"laws": [
{
"jurisdiction": "SE",
"work": "1949:381",
"title": "Föräldrabalk (1949:381)",
"displayLabel": "Föräldrabalk (1949:381)",
"issuingBody": "Justitiedepartementet L2",
"repealedAt": null,
"held": true
},
{
"jurisdiction": "SE",
"work": "1981:1292",
"title": "Förordning (1981:1292) om vårdnadsutredningar",
"displayLabel": "Förordning (1981:1292) om vårdnadsutredningar",
"issuingBody": "Justitiedepartementet",
"repealedAt": null,
"held": false
}
]
}The result has minutes to live, not days
Uploaded documents and analysis output are ephemeral by design. A retention sweep runs every fifteen minutes and deletes the job record, the stored result and the source file for every job that has not changed in the last fifteen minutes — so a finished analysis is gone between fifteen and thirty minutes after it completed, whether or not anyone read it.
Two things follow for an integrator, and both are easy to get wrong:
- Persist the result on your side the moment you receive it. Nothing here is a store you can come back to, and no endpoint can recover a swept job.
- Do not queue the fetch behind anything slow. A webhook received and put on a work queue that is drained hourly is a result that will be gone. Fetch on receipt, write it down, then process.
Reading a job does not destroy it: within the window, GET /v1/jobs/:jobId is repeatable and
answers the same result each time. DELETE /v1/jobs/:jobId is how you destroy it deliberately —
immediately after reading it, rather than waiting for the sweep.
curl https://api.granska.cloud/v1/jobs/job_7d41c9 \
-H "Authorization: Bearer $TOKEN"Quota, and when it is spent
A tenant has a run allowance per period. POST /v1/analyze and POST /v1/action each spend one run;
every other endpoint is free, the two that write profiles included. GET /v1/quotas reports the
limit, what has been used, and the instant the period resets.
The two profile-write endpoints are metered separately, against an hourly floor on configuration
writes that exists to bound a client stuck in a loop. It is not a plan allowance and nothing about it
should be priced against: it is set where no human and no scheduled integration reaches it.
GET /v1/laws/:jurisdiction/:work ticks the same floor, but only on a call that
actually fetches and stores a law the library did not hold — reading one it already has costs
nothing. GET /v1/quotas reports that counter too, and reorganising your profiles never costs you an
analysis you paid for.
A run is spent before the request is validated. The metering middleware increments the counter
before the handler ever looks at the body, so a POST /v1/analyze that comes back 400 because
profileId was missing has still cost you a run. This is deliberate — it is what stops an
unauthenticated flood of malformed requests from being free — but it means a retry loop around a
request that is malformed will empty the allowance without ever producing an analysis.
Read the error code before retrying. At 400 the request itself is wrong and retrying will not help;
see Errors.
Five endpoints answer with the rate-limit headers: the two that spend a run, the two that write
configuration, and the one that reads a law. The first four carry them on every response, rejections
included. GET /v1/quotas does not — it reads both counters without touching either. The exact
scope, and the one case where the law route answers without them, is in
Errors.
curl https://api.granska.cloud/v1/quotas \
-H "Authorization: Bearer $TOKEN"{
"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
}
}Sending a call without writing a client
If your organisation already has an account, an administrator can reach an API tester inside the
application at /admin/api-tester. It signs in with the same client id and
secret an integration uses, sends the real request to the real gateway, and shows the response and
its headers — which is the quickest way to check that a key works, or to see what an endpoint answers
before writing code against it.
It is part of the administration area, not a public playground: reaching it needs an account, and a call it sends spends the same quota as any other. Every page in this reference links to it, and the copy button on each request works just as well pasted into a terminal.