Read a job
https://api.granska.cloud/v1/jobs/:jobIdReads the state of one job, and its result once the job has finished.
Polling
The same id answers for an analysis started by POST /v1/analyze and for a
follow-up run by POST /v1/action.
While the job is running the response carries status, a numeric stage, a stageKey and a
human-readable stageName. Branch on status, which is QUEUED, IN_PROGRESS, COMPLETED or
FAILED.
stageKey is the one to show your users. It names the stage, so you can write the line in your
own reader's language rather than receive ours. The two flows do not share a vocabulary, and the
follow-up flow is not the analysis flow with a different prefix — there is no action-reviewing and
no action-consolidating. These fifteen keys are the whole set:
- An analysis reports
analysis-queued,analysis-preparing,analysis-reviewing,analysis-consolidating,analysis-complete. A run on a demonstration profile answers from a prepared example instead of calling a model, and reportsanalysis-demo-skipping-aiandanalysis-demo-finalisingwhere an ordinary run reports the reviewing and consolidating stages. - A follow-up document reports
action-queued,action-preparing,action-drafting,action-complete,action-document-ready. An outline-only run sharesaction-preparingwith the full run and reportsaction-skeleton-queued,action-skeleton-completeandaction-skeleton-document-readyin place of the three that name a written document.
Keys are added as the pipeline gains stages; a key you do not recognise means "no wording for this one yet", not an error, so fall back to something generic rather than showing the raw key. A job created before this field existed does not carry one.
stageName is the same information as a Swedish sentence, and it is prose rather than contract: it
is written for a person and changes without notice. It stays for clients that already read it.
Poll every few seconds. Reading a job spends no quota, so polling costs nothing but requests. A job
that reaches FAILED carries errorMessage, written for a person, and errorCategory, written for
your code to branch on. One category is worth handling on its own: INVALID_CONFIGURATION means the
profile the run needed no longer resolves — most often because it was
edited after the analysis was queued. Retrying the same document changes
nothing until the profile is fixed; every other category is ours, not yours.
A webhook removes the need to poll at all; you still fetch the result from here when it arrives.
curl https://api.granska.cloud/v1/jobs/job_7d41c9 \
-H "Authorization: Bearer $TOKEN"{
"jobId": "job_7d41c9",
"contractVersion": 1,
"status": "COMPLETED",
"stage": 3,
"stageKey": "analysis-complete",
"stageName": "DONE",
"createdAt": {
"_seconds": 1786295642,
"_nanoseconds": 0
},
"updatedAt": {
"_seconds": 1786295747,
"_nanoseconds": 0
},
"model": "gemini-3.7-flash",
"modelProfile": "default",
"result": {
"clinicalData": {
"identified_document_type": "Vårdnadsutredning",
"document_year": null,
"used_rules": {
"snip_sol_11_10": {
"id": "snip_sol_11_10",
"snippetKey": "sol-11-10-barnet-kommer-till-tals",
"name": "SoL 11 kap. 10 § — barnets rätt att komma till tals",
"authorityType": "STATUTE",
"source": "SFS 2001:453",
"text": "Barnet ska få relevant information. Barnet ska ges möjlighet att framföra sina åsikter …"
}
},
"internal_reasoning": "1. CLASSIFY: vårdnadsutredning. 2. MERGE: två observationer om barnets inställning avser samma brist. 3. LANGUAGE LOCK: … 4. CONSTRUCT: …",
"detected_language": "Swedish",
"executive_summary": "Analysen identifierade 1 rättslig avvikelse i 1 kategori. Bristen avser barnets rätt att komma till tals.",
"is_flawless": false,
"deduplicated_flaws": [
{
"page_reference": "Sida 4",
"exact_quote": "Barnet har inte hörts inom ramen för utredningen.",
"flaw_title": "Barnets inställning saknas",
"description": "Utredningen redovisar ingen kontakt med barnet och saknar därmed underlag för barnets egen inställning.",
"regulatory_references": [
{
"primary_binding_source": "SoL (2001:453) 11 kap. 10 §",
"primary_authority_type": "STATUTE",
"primary_label": "L1",
"supporting_guideline": null,
"supporting_authority_type": null,
"supporting_label": null,
"primary_source": {
"label": "L1",
"source_key": "snip_sol_11_10",
"reference": {
"jurisdiction": "SE",
"work": "2001:453",
"pinpoint": "kap_11_par_10§"
},
"authorityType": "STATUTE"
},
"supporting_source": null,
"primary_norm_level": "Lag",
"primary_id": "snip_sol_11_10",
"supporting_norm_level": null,
"supporting_id": null
}
],
"is_omission": true,
"source_flaw_ids": [
"FLAW-1",
"FLAW-4"
]
}
],
"consolidated_strengths": []
}
}
}The finished result
At COMPLETED the response gains a result object, and what is inside it depends on which call
created the job:
- an analysis puts the audit in
result.clinicalData— the summary, the findings, and the legal provision each finding is anchored in; - an action puts its output in
result.generatedDocument.
Asking for includeDiagnostics on the original call adds result.diagnostics: each reviewer's
individual output and the consolidating step's output for an analysis, the generator's own metadata
for an action. It works in every environment, production included, and it makes the response
considerably larger.
A finding's legal citation carries a source_key for an authored source and a typed reference for
a provision of published law. Both are looked up through
GET /v1/snippets, which is what makes a citation in a report clickable.
Every field of the analysis result
result.clinicalData carries these nine fields and no others. The list is the contract: a field
inside the engine that is not named here is not sent, and one that is added here is announced before
it ships.
identified_document_type— what the analysis classified the document as, in the output language.nullwhen it could not tell.document_year— the year of the audited document. Alwaysnullon this release; the engine does not extract it yet, and the field is published because it is delivered, not because it is useful.used_rules— every legal source the run assembled, keyed by the id a citation'ssource_keynames. Each value is the whole stored source record, text included, so this is by far the largest field in the response. Read a citation's source from here rather than fetching it again.internal_reasoning— the consolidating step's own working notes: how it classified the document, which reviewer findings it judged to be the same finding, and how it planned the summary. Written for the model rather than for a reader.detected_language— the language the text fields were actually written in, reported by the model itself. Normally the profile's output language; worth checking if you render into a language-specific view.executive_summary— an objective, quantitative summary of the audit.is_flawless—trueonly whendeduplicated_flawsis empty.deduplicated_flaws— the findings, merged across reviewers.consolidated_strengths— what the document did correctly, in the same shape.
A flaw carries page_reference and exact_quote (both nullable — an omission has nothing to
quote), a flaw_title, a description, is_omission, regulatory_references, and
source_flaw_ids: the ids of the individual reviewer findings that were merged into it, which is
what makes a consolidated finding traceable back through result.diagnostics. A strength is the
same minus is_omission and source_flaw_ids, with strength_title in place of flaw_title.
A citation in regulatory_references names the binding source and, optionally, the supporting
one: primary_binding_source and primary_authority_type with primary_label, and the
supporting_* trio which is null when the finding rests on statute alone. primary_source and
supporting_source are what those labels resolved to — { label, source_key, reference, authorityType }, where reference is a typed { jurisdiction, work, pinpoint } for a provision of
published law and null for an authored source. Either resolution is null when the model cited a
label the run never issued; the engine reports that rather than inventing a source, and so do we.
Four further names on a citation — primary_norm_level, primary_id, supporting_norm_level and
supporting_id — are deprecated and still sent. Read their successors.
exact_quote is in the document's own language, never translated, so that you can find it in the
source by searching for it. Every other text field follows the profile's output language.
The response says which contract it follows
Every answer from this endpoint carries contractVersion, currently 1, alongside jobId — on
a queued job as well as a finished one, so you can branch on it before there is a result to read.
It changes when a published field is removed, renamed, or changes meaning, and we tell you before that happens. It does not change when a field is added: a client that does not read a new field cannot be broken by one, so treat unknown fields as ignorable rather than as an error.
That is the promise this version number exists to make good on. Before it, the response was assembled from whatever the engine happened to store, which meant an internal rename could reach you unannounced in an ordinary deploy. It is now built from one published list — the nine fields above — and nothing else can leave the boundary.
{
"jobId": "job_7d41c9",
"contractVersion": 1,
"status": "COMPLETED",
"stage": 3,
"stageKey": "analysis-complete",
"stageName": "DONE"
}
The result has minutes to live
Reading a job does not destroy it: within the window this call is repeatable and answers the same result each time.
What destroys it is the retention sweep, which runs every fifteen minutes and deletes the job, the
stored result and the source file for anything that has not changed in the last fifteen minutes. A
finished analysis is therefore gone between fifteen and thirty minutes after it completed, read or
unread, and a 404 afterwards is the retention model rather than a fault.
Write the result down the moment you have it. No endpoint can recover a swept job, and no support request can either — the data is not somewhere else, it is deleted. If you receive callbacks onto a work queue, fetch the result on receipt rather than when the queue is next drained.
DELETE /v1/jobs/:jobId destroys it immediately, for a caller who would
rather not wait for the sweep.
Timestamps are not ISO 8601 here
createdAt and updatedAt: this endpoint passes the stored timestamps through as raw database values —
{ "_seconds": 1786295642, "_nanoseconds": 0 } — rather than as ISO 8601 strings. Convert them
yourself: new Date(_seconds * 1000 + _nanoseconds / 1e6).
It is inconsistent with the webhook payload, whose updatedAt is ISO
8601, and the inconsistency is a known defect in this endpoint rather than a design. It will be
corrected. Until it is, do not build a string parser against this field, and do not assume the two
sources agree in format just because they agree in meaning.
{
"jobId": "job_7d41c9",
"status": "IN_PROGRESS",
"stage": 1,
"stageKey": "analysis-reviewing",
"stageName": "Granskningen pågår...",
"createdAt": { "_seconds": 1786295642, "_nanoseconds": 0 },
"updatedAt": { "_seconds": 1786295705, "_nanoseconds": 0 }
}
Request
| Parameter | Description |
|---|---|
jobIdstring·path·required | The id POST /v1/analyze or POST /v1/action returned. |
Errors
A job belonging to another tenant answers 403, and a job that never existed or has been swept
answers 404. Do not read a 404 as "still queued" — a queued job answers 200 with
status: "QUEUED".
| Error | When |
|---|---|
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 job exists but belongs to another tenant. Not probed: it needs a job created by a second tenant. |
404NOT_FOUND | No job with that id, or the retention sweep has already removed it. Reading a job does not delete it; `sweepStaleData` does, once the job has been untouched for 15 minutes. |
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 poll a job from the API tester at
/admin/api-tester, which fills the :jobId segment in for you.