Run an action
https://api.granska.cloud/v1/actionQueues a follow-up action over a finished analysis and answers immediately with a job id. Consumes one run from the tenant's quota.
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.
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": []
}
}'{
"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.
| Parameter | Description |
|---|---|
clinicalDataReducer·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. |
jobIdstring·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. |
actionTypestring·body·required | Which action to run. GET /v1/actions lists the ones this tenant is licensed for. |
profileIdstring·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. |
includeDiagnosticsboolean·body | Adds the action job's diagnostic output to the finished job. |
webhookUrlstring·body | Called when the job finishes, instead of polling GET /v1/jobs/:jobId. |
webhookSecretstring·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
| Error | When |
|---|---|
400BAD_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. |
401UNAUTHORIZED | The Authorization header is missing, is not a readable bearer token, or names no tenant. |
401TOKEN_EXPIRED | The access token was issued by this gateway and has since expired. Not probed: it needs a token older than its own lifetime. |
403FORBIDDEN | 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. |
404NOT_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. |
409CONFLICT | 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. |
429TOO_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. |
500INTERNAL_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.