GRANSKA

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.

Request
curl https://api.granska.cloud/v1/health
Response
{
  "status": "OK",
  "gateway": "B2B"
}

The shape of a run

Five calls, of which two are optional.

  1. Get a token. POST /v1/oauth/token exchanges your client id and secret for a bearer token that lives one hour. See Authentication.
  2. 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 you PUT the file straight at — the document never passes through the API itself.
  3. Start the analysis. POST /v1/analyze names a profile and a document source, and answers immediately with a job id. The work happens asynchronously; the call does not block.
  4. Collect the result. Either poll GET /v1/jobs/:jobId until it reports COMPLETED, or give POST /v1/analyze a webhookUrl and be told. A webhookSecret signs the callback with HMAC-SHA256 so you can verify it came from here.
  5. Act on it, optionally. POST /v1/action runs a follow-up action — a drafted appeal, a plain-language summary — over a finished analysis.
Request
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..."
  }'
Response
{
  "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/profiles hands back is what GET /v1/profiles lists, what PATCH /v1/profiles/:id takes in its path, and what POST /v1/analyze accepts. 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/:id takes "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 mention status never 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 a 400 naming 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§, not 7 §; kap_6_par_1§, not 6 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/:work fetches, parses and stores it on the spot, so the same reference is accepted on the retry. See below.
Request
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§" }
        ]
      }
    ]
  }'
Response
{
  "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.

  1. Search the catalogue. GET /v1/laws matches free text against every law's number and title in a jurisdiction. Each hit carries the work, a displayLabel to put in front of a person, and — the field the route exists for — held, which says whether a citation to that law resolves here. SE and NO resolve today; another is refused by name rather than answered with an empty list, because an empty list would read as no such law.
  2. Read the work's provisions. GET /v1/laws/:jurisdiction/:work returns every provision of one law with its pinpoint and the label a lawyer writes. A law we do not hold is fetched from the national source, parsed and stored while you wait, so held: false means not yet wherever the search response reports fetchable: true — which both Sweden and Norway do.
  3. Copy the pinpoint into the profile. Send the pinpoint back exactly as it arrived — kap_6_par_2a§, never the 6 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.

Request
curl -G https://api.granska.cloud/v1/laws \
  --data-urlencode "jurisdiction=SE" \
  --data-urlencode "query=föräldrabalk" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "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.

Request
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.

Request
curl https://api.granska.cloud/v1/quotas \
  -H "Authorization: Bearer $TOKEN"
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
  }
}

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.