GRANSKA

Run an action

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

Queues a follow-up action over a finished analysis 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

POST /v1/action runs an action. POST /v1/actions, with an s, creates one. The two paths differ by one character and do opposite things.

An action turns a finished audit into a document — the grounds for an appeal, a plain-language summary, a letter. You name the audit — either by sending it back or by naming the job it came from — name the action, and get a jobId; the work is asynchronous exactly as POST /v1/analyze is, and you collect the result from GET /v1/jobs/:jobId under result.generatedDocument.

A run is spent before the request is validated. As on the analysis call, the counter increments before the handler looks at the body, so a 400 here has still cost a run.

Request
curl -X POST https://api.granska.cloud/v1/action \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "actionType": "action_overklagande",
    "profileId": "lss_utredning",
    "clinicalData": {
      "internal_reasoning": "…",
      "detected_language": "Swedish",
      "executive_summary": "Granskningen fann 2 brister…",
      "is_flawless": false,
      "deduplicated_flaws": [
        { "flaw_title": "Ensidigt urval av underlag", "…": "…" },
        { "flaw_title": "Bedömning utan redovisad grund", "include_in_action": false, "…": "…" }
      ],
      "consolidated_strengths": []
    }
  }'
Response
{
  "success": true,
  "jobId": "job_b2e08a",
  "status": "QUEUED"
}

Request body

The audit is named one of two ways, and exactly one of them: clinicalData is the audit fed back in verbatim — the object GET /v1/jobs/:jobId returned as result.clinicalData — and jobId is the analysis job it came from, which we then read it off ourselves. A request carrying both is a 400, and so is one carrying neither. actionType is an id from GET /v1/actions.

If you already send a jobId here, check it. Until now this route had no such field, so one sent beside clinicalData was ignored and the action ran off the audit you pasted. It is a second name for the audit now, and sending both is a 400 — the two can name different audits, and before this only one of them was ever going to run. Drop whichever of the two you like; they produce the same action. A jobId that could not name a job at all — a number rather than a string, or one containing / — is also a 400.

jobId is there so you do not have to carry the report. An audit of a long investigation is a large object, and an integration that only ever passes it from one of our calls to the next gains nothing by holding it — an AI assistant driving this API through a tool call cannot realistically hold it at all. Naming the job is the same request without the payload:

curl -X POST https://api.granska.cloud/v1/action \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jobId": "b2b_a1b2c3d4",
    "actionType": "action_overklagande",
    "profileId": "lss_utredning"
  }'

The two forms produce the same action from the same audit: we read the job through the same projection GET /v1/jobs/:jobId answers through, so neither route sees anything the other does not.

With one exception, and it is the reason to send the audit rather than name it. Leaving a finding out of the action — include_in_action below — is a mark you make on the audit you send. Naming a jobId sends no audit, so there is nowhere to put the mark: we read the stored audit, which carries every finding the analysis produced. There is no error to expect here, because there is no field to reject; the action is simply written from all of them. An integration that excludes findings has to send clinicalData.

The job has to still be there. Reading a job does not delete it and does not extend its life either — scheduled cleanup normally removes a job and its audit between 15 and 30 minutes after the job's last update 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. Once the job is removed, jobId answers 404 NOT_FOUND and the audit you kept is the only copy you can still get through the API: send it as clinicalData. A job of yours that has not finished, one that failed, and an action job answer 409 CONFLICT. A job your credential did not start answers 403 FORBIDDEN: another organisation's job, one another API client of yours started, or one a person started in the web application or the API tester.

profileId is required — the same id you passed to POST /v1/analyze to produce the audit. A request without it is a 400. It used to be optional, and the engine then worked out which profile produced the audit by matching the document type: an organisation with more than one profile for the same document type could silently land on the wrong one, which means the wrong legal framework and possibly the wrong output language. Named explicitly, the profile is validated against your licences the same way the analysis call validates it — and GET /v1/actions?profileId= lists the actions that profile is written for, so the actionType you send here can be chosen from that list rather than from all of them.

The findings decide which profile the action is written from, and profileId has to agree with them. Every finding an analysis delivers carries profile_id, naming the granskningsprofil whose review produced it, and finding_id beside it — see the fields on a finding. Both are recognised here, and the action resolves its profiles from the tags rather than from the profileId you send. Send back the findings the analysis delivered together with the profileId it ran under and nothing changes. Send a profileId naming a different profile than the findings do — or findings from two different reviews in one payload — and the call answers 400 BAD_REQUEST naming both sides, rather than quietly writing the document from one of them. An audit produced before those fields existed carries no tags and is accepted on profileId alone, exactly as before.

Fields the audit schema does not recognise are ignored, so an audit you have annotated on your own side can be sent back as it stands.

Leaving a finding out of the action. Set include_in_action: false on any entry in deduplicated_flaws or consolidated_strengths and the action is written as if that finding did not exist. The field is optional and absent means included, so an audit sent back unchanged behaves exactly as it always has. It is a mark on the audit in the request body, so it is available on the clinicalData form only, never on the jobId one.

Filtering the arrays yourself is not the same thing, and that is why the field exists. The audit carries executive_summary, which names and counts the findings in prose, so an excluded finding would still be described to the generator by a payload whose arrays no longer hold it. So whenever at least one entry is excluded, executive_summary is left out of the generation too; it is not rewritten or replaced. is_flawless is recomputed from the findings that remain whenever you exclude one, so an exclusion can never leave it contradicting them. Exclude nothing and it is passed through exactly as you sent it.

Excluding a finding costs nothing — no second analysis, no extra generation, no quota. Nothing about the audit itself changes: GET /v1/jobs/:jobId keeps returning every finding the analysis produced, and only the action is scoped.

The fields marking which text is the audited document's — quoted_locator and fenced_verbatim, see which words are the document's — are recognised here, so an audit sent back exactly as it was received keeps its markings instead of having them quietly dropped. Both are optional: an audit produced before they existed is accepted unchanged. The generation fences the whole audit again under a fresh nonce of its own, so a stale fence in what you send changes nothing.

ParameterDescription
clinicalData
Reducer·body·exactly one of clinicalData / jobId
The finished analysis to act on, exactly as GET /v1/jobs/:jobId returned it. Send this or jobId, never both. Set include_in_action: false on any entry in deduplicated_flaws or consolidated_strengths to leave that finding out of the action — the entry is dropped, and so is executive_summary, which names the findings in prose. Absent means included.Unconditionally required until #1385, which added jobId as the other way to name the same analysis. Exactly one of the two is still required — see requiresExactlyOneOf — so a request that carries neither is the same 400 it always was. include_in_action is #859: filtering the arrays yourself is not enough, because executive_summary describes the findings a shorter array no longer holds.
jobId
string·body·exactly one of clinicalData / jobId
The analysis to act on, named rather than pasted: the id POST /v1/analyze returned. The gateway reads the finished analysis off that job itself. Send this or clinicalData, never both. include_in_action cannot be used with this form — it is a mark on the analysis you send, and this form sends none, so the action is written from every finding.The job has to be one of yours and finished, and it has to still exist: scheduled cleanup normally removes a job between 15 and 30 minutes after its last update, and the analysis goes with it, 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. After removal, clinicalData is the way. Reading a job here changes nothing about how long it lives. The include_in_action limit is an absence rather than a refusal (#1571, review round 1): there is no field on this form to reject, so a caller that wants to exclude a finding has to send clinicalData instead.
actionType
string·body·required
Which action to run. GET /v1/actions lists the ones this tenant is licensed for.
profileId
string·body·required
The profile the analysis was produced with — the same id you passed to POST /v1/analyze, a granskningspaket included. Omitting it is a 400. Name a bundle and it is expanded into its member profiles: the action is one document written from the union of both members' law, and the findings you send back must carry profile_id tags naming that bundle's own members and nothing else.Required since #681, and this row said otherwise until #1061. What it used to describe — omitting it and letting the gateway match on document type — matched by a non-unique label and could hand the action a different profile, in a different language, citing another country's law, than the analysis had used. GET /v1/actions?profileId= lists the actions this profile is written for.
includeDiagnostics
boolean·body
Adds the action job's diagnostic output to the finished job.
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.

An audit stored before the citation rework is rejected

clinicalData is validated against the same schema an analysis produces. A result saved before the citation fields were reworked lacks primary_authority_type and primary_label, both required, and the call answers 400 BAD_REQUEST with the validation error in message.

There is no translation path, and there will not be one. primary_label has to be a label the engine issued during the very analysis that produced the finding, and those labels no longer exist for an analysis that has been delivered and deleted. An old result cannot be recomputed into the new shape — it can only be produced again.

What to do: run the document through POST /v1/analyze again and send the fresh result here. If you hold a queue of saved audits waiting for action generation, drain it before upgrading. A result produced after the rework can be sent back unchanged, as always — the break is across one release boundary, in one direction, once.

The same applies to the stored results in GET /v1/examples/:profileId, which are also from before the rework.

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Invalid clinicalData: ..."
  }
}

Diagnostics and webhooks

includeDiagnostics adds this action's own diagnostic output — the model, the action type, the agent name, the generator's raw output and token usage — under result.diagnostics on the finished job. It is scoped to this action and is not the broader internal debug bundle. It works in production.

webhookUrl and webhookSecret behave exactly as they do on the analysis call, down to the payload, the retry schedule and the signature. See Webhooks.

Errors

ErrorWhen
400
BAD_REQUEST
actionType or profileId is missing, neither clinicalData nor jobId was sent, both were sent, jobId is not a string that could name a job at all, clinicalData failed validation, the action type is not one this tenant can run, or the profile_id tags on the findings name a granskningsprofil the named profile is not — for a granskningspaket, one that is not among its members.
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, is not licensed for the action or profile it named, or your credential did not start the job jobId names (another organisation's job, one another API client of yours started, or one a person started in the web application or the API tester). Not probed: it needs a licence the probing tenant deliberately lacks.
404
NOT_FOUND
jobId names no job — it never existed, or the retention sweep has already removed it. Send the analysis as clinicalData instead; nothing here brings a swept job back.
409
CONFLICT
jobId names a job of yours that carries no analysis to act on: it has not finished, it failed, or it is itself an action generation. Not probed: it needs a job in one of those states.
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 runs against your own tenant and spends a real run.