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 spent before the request is validated. The metering middleware increments the counter before the handler looks at the body, so a call rejected with 400 has still cost a run. A retry loop around a malformed request will empty the period's allowance without producing a single analysis — read the error code before retrying, and at any 4xx change the request rather than repeating it.

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"
}

Request body

Give the document exactly one way: inline, by URL, or by prior upload. profileId names the audit to run and is always required — GET /v1/profiles lists the ones 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.
pdfBase64
string·body
The document, inline as base64. One of pdfBase64, pdfUrl or pdfUploaded must be given.
pdfUrl
string·body
A URL the gateway downloads the document from. One of pdfBase64, pdfUrl or pdfUploaded must be given.
pdfUploaded
boolean·body
True when the document was already uploaded through POST /v1/upload-url. Implied when jobId is given.
jobId
string·body
The job id POST /v1/upload-url returned, so the analysis reads the file that was uploaded against it.
includeDiagnostics
boolean·body
Adds per-worker and reducer output to the finished job under result.diagnostics.
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 two keys naming one worker fail the job instead of one of them being applied to nothing.
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 three document sources

jobId from POST /v1/upload-url — the one to build against. The file is already in storage, so this call carries an id and nothing else. Passing jobId implies pdfUploaded: true.

pdfUrl — a URL the gateway downloads from. It must be https:, and it must not point inside a private network: loopback, link-local 169.254.*, the private ranges 10.*, 172.16–31.* and 192.168.*, and the cloud metadata host are all refused. That is server-side request forgery protection, not a configuration you can relax, and a URL that fails it answers 400 BAD_REQUEST before anything is fetched. A URL that passes but cannot be downloaded — a 404 at your end, an expired signed link — is also a 400, with the upstream status in the message. The download happens inside the request, so a slow host slows this call down.

pdfBase64 — the document inline. Simplest to write and the worst to operate: the encoded string has to be received and decoded before anything is queued. Fine for a one-off test, not for a production integration.

Giving none of the three is a 400.

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.
  • snippetIdssnippetKey values of existing, already reviewed legal sources to add to that reviewer's material for this job. At most 10 per targeted worker.
  • At most 10 targeted workers per job. 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: the retention sweep removes a finished job within fifteen minutes of its last change. 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 is held to the same rule as pdfUrl: https: only, 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 all of them have already spent a run. 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.

ErrorWhen
400
BAD_REQUEST
No document source was given, profileId is missing, dynamicContext failed validation, requestedWorkerIds matched no worker, the pre-uploaded file is not there, or the pdfUrl could not be fetched.
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.
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.

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.