GRANSKA

Start an analysis

POSThttps://api.granska.cloud/v1/analyze

Queues an analysis of one document and answers immediately with a job id. Consumes one run from the tenant's quota.

Bearer tokenSpends 1 run — even when the request is rejectedAnswers with the rate-limit headers

Analysis is asynchronous, and there is no synchronous mode. The call answers 202 immediately with a jobId; the work happens afterwards. You find out it finished either by polling GET /v1/jobs/:jobId or by giving this call a webhookUrl.

A run is counted before the request is validated, and given back if the request fails. The metering middleware increments the counter before the handler looks at the body, so the X-RateLimit-Remaining header on an error still shows the count with this request in it. A request that ends in an error, 400 included, gets that run back before it is answered; only a request that starts an analysis keeps it. Read the error code before retrying, and at any 4xx change the request rather than repeating it: a retry loop gains nothing.

A granskningspaket costs one run per granskningsprofil it contains. A paket is not a discount on two analyses; it runs each member profile as its own full review, with its own workers, its own consolidation and its own bill, and the meter counts it that way. So a two-member paket takes two runs from the period, and a period with one run left refuses it — 429 TOO_MANY_REQUESTS, with nothing charged for the refused request. The X-RateLimit-Remaining header on the response is the count after the whole run has been charged. An ordinary profile is one run, exactly as before.

Request
curl -X POST https://api.granska.cloud/v1/analyze \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jobId": "job_7d41c9",
    "profileId": "lss_utredning",
    "webhookUrl": "https://kunden.example/hooks/granska",
    "webhookSecret": "whsec_4a1f..."
  }'
Response
{
  "success": true,
  "jobId": "job_7d41c9",
  "status": "QUEUED"
}

Request body

Give the document as the jobId of an upload, and profileId to name the audit to run. Both are always required — GET /v1/profiles lists the profiles your tenant is licensed for.

ParameterDescription
profileId
string·body·required
Which analysis profile to run. GET /v1/profiles lists the ones this tenant is licensed for.
jobId
string·body·required
The job id POST /v1/upload-url returned, after the document was PUT to the upload URL that came with it. This is the only way to give an analysis its document: pdfBase64 and pdfUrl are no longer accepted, and a request that carries either is refused with 400 before anything else is read or a run is spent. It starts one job: sent again once a job stands under it, the request is refused with 409 CONFLICT.pdfBase64 (the document inline) and pdfUrl (the gateway downloading it) were retired by ADR-0076: neither could be retried without a second charge, and neither had a ceiling a caller could rely on. pdfUploaded and an analyze-side uploadAttestation are no longer published; a body that still carries them is accepted and they are ignored, because jobId already says the document was uploaded and the attestation was given on POST /v1/upload-url.
includeDiagnostics
boolean·body
Adds each reviewer's own output and every consolidation step's output to the finished job under result.diagnostics. The consolidation half is result.diagnostics.reducers, one entry per review keyed by its profileId — one entry for an ordinary run, one per member for a review package. The older result.diagnostics.reducer holds the same record for an ordinary run and is deprecated; it is null for a review package, which has one consolidation per review and no single answer to give.
dynamicContext
DynamicContext·body
Supplementary legal context merged into the targeted workers' prompts for this one job.Never persisted — it lives only for the duration of the request. Its workerContext keys name worker records rather than strings, on the same rule as requestedWorkerIds (#514), so either spelling GET /v1/profiles has published for a worker reaches it. A key that names no worker this run executes — including one the profile holds but requestedWorkerIds left out — and two keys naming one worker are both a 400 naming only the offending keys, answered before any job, stored PDF or queued task exists (#2107); until then they answered 202 and the job failed later. workerContext cannot be given for a granskningspaket at all: its keys name workers inside one profile and a bundle runs one profile per lane, so name a member profile instead. The rest of the object is accepted for a bundle and reaches every lane.
webhookUrl
string·body
Called when the job finishes, instead of polling GET /v1/jobs/:jobId.
webhookSecret
string·body
Signs the webhook call with HMAC-SHA256 so the receiver can verify it came from here.

The document: one upload, one analysis

jobId from POST /v1/upload-url is the only way to give an analysis its document. Upload the file to the URL that call returns, then send its jobId here. The file is already in storage, so this call carries an id and nothing else, and answers at once whatever the document's size.

One upload starts one analysis. Sending the same jobId again, once a job stands under it, answers 409 CONFLICT whatever state that job is in: nothing is overwritten and no run is spent. When the job already stands under the id as the request arrives, it is answered before the run quota is consulted, so it is 409 even when this period's runs are used up. A repeat racing your last run of the period, sent before the first request has created the job, may answer 429 TOO_MANY_REQUESTS: nothing is overwritten or charged, and GET /v1/jobs/:jobId shows the job. A client that timed out and does not know whether its first call landed polls GET /v1/jobs/:jobId instead of repeating it; to analyse the document again, upload it again under a new jobId.

pdfBase64 and pdfUrl are no longer accepted. A request that carries either — the document inline, or a URL for the gateway to download — is refused with 400 BAD_REQUEST naming the field, before anything else in it is read and before your run quota is counted. Neither could be retried safely: a repeat after a timeout started a second analysis and spent a second run. Upload the document instead. pdfUploaded, which only ever meant "I used POST /v1/upload-url", is no longer needed: jobId says so. A request that still sends it, or an uploadAttestation, is accepted and those two fields are ignored.

The upload agreement is judged when the upload URL is issued. A jobId carries the confirmation its POST /v1/upload-url was given, and only to the API key that asked for it: another key, or an upload that was never admitted, is 409 UPLOAD_NOT_ADMITTED, and the document has to be uploaded again. That refusal comes before your run quota is counted, so it costs no run. Only an administrator of your organisation can accept the agreement, in the GRANSKA app; GET /v1/legal-agreement says whether they have.

dynamicContext — local rules for one run

If you audit documents for many similar entities that share one profile but each have a few rules of their own — hundreds of housing associations with their own statutes, say — you do not need a profile per entity. Send the small amount of unique context as dynamicContext on this call. It is merged into the targeted reviewers' prompts in memory for this job only and is never written to the database.

workerContext maps a worker id to the rules that apply to that reviewer for this job. Rules are always aimed at a named reviewer; they are never applied to the whole profile. The ids come from GET /v1/profiles — read them, do not type them.

  • customRules — free text. At most 10 rules per targeted worker, 1000 characters each. The engine treats them as supplementary, legally unreviewed context: they cannot override the binding framework the profile carries.
  • snippetIds — snippetKey values of existing, already reviewed legal sources to add to that reviewer's material for this job. At most 10 per targeted worker.
  • How many workers one job may target is your organisation's own ceiling, not one number for everyone: GET /v1/quotas reports it as limits.maxWorkersPerProfile, and one worker more is a 400. entityId and entityName are optional labels for your own traceability, 200 characters each.

A worker id that matches no reviewer in the resolved profile fails the job. The call still answers 202; the job then reaches FAILED with errorMessage naming the ids that did not match. Silently ignoring them would be worse — you would believe your targeted context had been applied when it had not.

An id is matched by the reviewer it names rather than by the characters you send, so an older spelling of a reviewer still reaches it. The one thing that cannot work is two keys naming the same reviewer in one workerContext — one of the two entries would have to be discarded, so the job fails naming the second instead.

The object is validated strictly: an unrecognised field is rejected with 400, not dropped.

{
  "jobId": "job_7d41c9",
  "profileId": "brf_standard",
  "dynamicContext": {
    "entityId": "brf_eken_123",
    "entityName": "BRF Eken",
    "workerContext": {
      "worker_hyresratt": {
        "customRules": [
          "Särskild avgift för andrahandsupplåtelse är tillåten enligt 7 § i stadgarna."
        ],
        "snippetIds": ["praxis_andrahand_2024"]
      }
    }
  }
}

Webhooks

Give this call a webhookUrl and the engine POSTs to it when the job reaches COMPLETED or FAILED, so you do not have to poll. The same two fields work on POST /v1/action.

The callback carries the job's identity and outcome — not the result. Fetch that from GET /v1/jobs/:jobId when the callback arrives, and fetch it promptly: scheduled cleanup normally removes a finished job between fifteen and thirty minutes after its last change when a successful run reaches that job within its per-run capacity. Two missed four-minute sweep runs fit within thirty minutes, including execution, only if the next run succeeds and reaches the job. Further failed runs or a backlog beyond that run's capacity can delay removal beyond thirty minutes and require later successful runs. Note that updatedAt here is ISO 8601, which the job endpoint's own timestamps are not.

Delivery runs through a task queue: 5 attempts, backing off from 60 seconds to at most one hour. Any non-2xx response counts as a failure and is retried, so a receiver that is briefly down loses nothing — but a receiver that answers 200 and then drops the message loses the run. Answer after you have stored it.

webhookSecret signs the body with HMAC-SHA256 and puts the hex digest in the X-UG-Signature header. Verify it before trusting the payload, and compare in constant time. Without a secret the header is absent rather than empty.

The webhookUrl must be https:, and never an address inside a private network. A URL that fails it is dropped without being attempted and without an error reaching you — the job still completes, and you simply never hear about it. If callbacks are not arriving at all, check the scheme first.

{
  "jobId": "job_7d41c9",
  "status": "COMPLETED",
  "errorMessage": null,
  "updatedAt": "2026-08-05T09:15:47.000Z"
}
import crypto from "crypto";

function verifyWebhook(rawBody: string, signature: string, secret: string): boolean {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Errors

400 covers every way the request can be wrong, and none of them costs a run: a retired pdfBase64 or pdfUrl is refused before the run quota is counted, and every other 400 has its run given back before it is answered. 403 means your tenant is not licensed for the profile it named; 404 means the licence points at a profile that no longer exists, which is a configuration problem to report rather than to retry. 409 CONFLICT means the jobId already names a job (a repeat racing your last run may see 429 instead); read that job rather than sending the call again. 409 UPLOAD_NOT_ADMITTED is the upload agreement's, and error.details says what to do; 503 means the agreement could not be checked, and is retried after Retry-After.

ErrorWhen
400
BAD_REQUEST
The body carries pdfBase64 or pdfUrl, which are no longer accepted (the message names the field; nothing else is read and no run is spent), no jobId was given, the jobId is not one POST /v1/upload-url could have returned, profileId is missing, dynamicContext failed validation, a requestedWorkerIds entry matched no worker, a dynamicContext.workerContext key matched no worker the run executes or named one another key already names, the named profile is a granskningspaket and the request also selects workers or sends per-worker context, or nothing was uploaded under the jobId.
401
UNAUTHORIZED
The Authorization header is missing, is not a readable bearer token, or names no tenant.
401
TOKEN_EXPIRED
The access token was issued by this gateway and has since expired. Not probed: it needs a token older than its own lifetime.
403
FORBIDDEN
The tenant has no configuration, or is not licensed for the profile it named. Not probed: it needs a profile the probing tenant is deliberately not licensed for.
404
NOT_FOUND
The profile is licensed for the tenant but no longer resolves to a profile. Not probed: it needs a licence pointing at a deleted profile.
409
UPLOAD_NOT_ADMITTED
The jobId names an upload that was not admitted under the upload agreement when its URL was issued — none was recorded while the agreement is enforced, or it was admitted to another API client. details.reason says which. Upload the document again. Not probed: it needs the agreement enforced.
409
CONFLICT
The jobId from POST /v1/upload-url already names a job, whatever state that job is in: an upload starts one analysis, and the first request under it already did. Nothing is overwritten and no run is spent; when the job already stands under the id as the request arrives, it is answered before the run meter, so a used-up quota does not turn it into a 429. A repeat racing the tenant's last run, sent before the first request has created the job, may answer 429 TOO_MANY_REQUESTS; polling GET /v1/jobs/:jobId shows the job either way. To learn whether an earlier request landed, poll GET /v1/jobs/:jobId; to analyse the document again, upload it again under a new jobId from POST /v1/upload-url. Not probed: it needs a job that already exists under an uploaded document.
429
TOO_MANY_REQUESTS
The tenant has spent its runs for the current period. GET /v1/quotas says when the period resets. Not probed: it would have to spend a real quota to reach.
500
INTERNAL_ERROR
An unexpected server-side failure. Not probable from outside — reaching it means something is wrong.
503
SERVICE_UNAVAILABLE
The upload agreement could not be checked; details.reason is AGREEMENT_UNAVAILABLE and a Retry-After header says when to try again. Never an answer about acceptance. Not probed: it needs an injected infrastructure failure.

Send it without writing a client

If your organisation already has an account, an administrator can send this call from the API tester at /admin/api-tester. It is the real gateway against your own tenant, so the run it starts spends real quota.