GRANSKA

Read a job

GEThttps://api.granska.cloud/v1/jobs/:jobId

Reads the state of one job, and its result once the job has finished.

Bearer tokenSpends no quota

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 reports analysis-demo-skipping-ai and analysis-demo-finalising where 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 shares action-preparing with the full run and reports action-skeleton-queued, action-skeleton-complete and action-skeleton-document-ready in 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.

Request
curl https://api.granska.cloud/v1/jobs/job_7d41c9 \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "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. null when it could not tell.
  • document_year — the year of the audited document. Always null on 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's source_key names. 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_flawlesstrue only when deduplicated_flaws is 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

ParameterDescription
jobId
string·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".

ErrorWhen
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 job exists but belongs to another tenant. Not probed: it needs a job created by a second tenant.
404
NOT_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.
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 poll a job from the API tester at /admin/api-tester, which fills the :jobId segment in for you.