GRANSKA

Create a profile

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

Creates an analysis profile and the workers it runs. The profile is created as a draft.

Bearer tokenSpends no quotaAnswers with the rate-limit headers

What you are creating

A profile is one kind of audit: which reviewers run, which legal sources each of them may cite, which document type it expects, and what language it answers in. Until now it existed only as something an administrator built in the web application; this endpoint builds the same object, and it is the same code that validates both.

You send the profile's name, the document type it audits, and the reviewers. A reviewer is a name, an instruction in its own words, and the legal sources it holds — provisions named directly, rule sets named by key, or both. Rule sets of your own that do not exist yet can be written in the same call, in snippets — see below.

Request
curl -X POST https://api.granska.cloud/v1/profiles \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "LSS-utredning",
    "documentType": "LSS",
    "workers": [
      {
        "name": "Rättslig grund",
        "instructions": "Weigh the investigation against the conditions for the measure applied for.",
        "rules": [
          { "jurisdiction": "SE", "work": "1993:387", "pinpoint": "par_7§" },
          { "jurisdiction": "SE", "work": "1993:387", "pinpoint": "par_9a§" }
        ]
      }
    ]
  }'
Response
{
  "success": true,
  "profileId": "lss_utredning_k4m2",
  "workerIds": [
    "tenant_9f3a_worker_rattslig_grund_p8x1"
  ],
  "status": "DRAFT"
}

The id you get back is the id everything else takes

profileId is what the profile is called everywhere: it is how GET /v1/profiles lists it, what you send as profileId to POST /v1/analyze, and what addresses it for PATCH /v1/profiles/:id, and what removes it again with DELETE /v1/profiles/:id if you created it by mistake. Store that one string and use it for all four.

workerIds are the reviewers, and they are spelled differently — longer, and carrying your organisation's own identifier. That is not something to correct or trim: send each one back exactly as you were given it. It is the same string GET /v1/profiles lists for that reviewer, and the same one POST /v1/analyze takes — a reviewer is matched by the record it names rather than by the characters you send, so an id you cached earlier keeps working too.

It arrives as a draft, deliberately

A profile created here is a draft. Your own integration can run it immediately — GET /v1/profiles returns it and POST /v1/analyze accepts it — but the people who use the web application do not see it in their list until it is published.

That is the point of the draft: minting something already live is a different risk from changing something that is, so building a profile and putting it into circulation stay two separate acts. Sending status here is refused rather than ignored, so a request never comes back 201 having quietly not published anything.

Publishing is one more call, and it is yours to make: PATCH /v1/profiles/:id with {"status": "PUBLISHED"}. Nobody has to open the admin panel for it.

Whichever jurisdictions your provisions belong to are the jurisdictions the profile is stored against. You do not send them, and sending them alongside cited provisions is a 400.

The one exception is a profile that cites no statute at all — a profile built entirely on your own rule sets. There is nothing to derive from, so jurisdictions is required, and if your organisation has a default it is used instead. If neither is there, the answer is a 400 naming jurisdictions: send it, or cite a provision and let it be derived.

Either way, a jurisdiction we do not carry is refused naming it and listing the ones we do. It is matched exactly: se is not SE.

Your own rules can be created in the same call

A profile is often built on rules nobody else holds — an association's own regulations, an employer's own guidelines. snippets writes those rules together with the profile and the reviewers that cite them, so one request creates all three, and nothing is left behind if any part of it is refused.

{
  "name": "Granskning av ordningsregler",
  "documentType": "Styrelsebeslut",
  "jurisdictions": ["SE"],
  "snippets": [
    {
      "ref": "ordningsregler",
      "name": "§ 1 Ordningsregler",
      "source": "Ordningsregler för Brf Eken, antagna 2025-03-11",
      "authorityType": "LOCAL_REGULATION",
      "content": "§ 1 …"
    }
  ],
  "workers": [
    { "name": "Ordningsgranskare", "snippetIds": ["ordningsregler"] }
  ]
}

answers 201:

{
  "success": true,
  "profileId": "granskning_av_ordningsregler_k4m2",
  "workerIds": ["tenant_9f3a_worker_ordningsgranskare_p8x1"],
  "status": "DRAFT",
  "snippets": [
    {
      "ref": "ordningsregler",
      "id": "tenant_9f3a_snippet_ordningsregler_3fa91c07",
      "snippetKey": "tenant_9f3a_snippet_ordningsregler_3fa91c07"
    }
  ]
}

ref is a label for this one request, and it is never stored. You choose it — lower-case letters, digits, _ and -, unique within the request — and a reviewer cites the entry by putting the same string in snippetIds. The server mints the source's durable identity and stores its key in snippetIds in place of your ref, so you never have to carry an identifier from one call into the next. Every entry must be cited by at least one reviewer: an uncited one would be a source nobody reads that still counts against your limit.

Two identities come back beside each ref, as they do from POST /v1/snippets. snippetKey is what a reviewer cites — send it in snippetIds when a later request names this source without writing it again. id addresses the record, for PATCH /v1/snippets/:id when you want to change what it says; this endpoint never edits a stored source.

Each entry takes exactly the fields POST /v1/snippets takes, is refused by the same rules, and has the field named with its position — snippets[0].text cannot be set: …. Its content is compiled into the citable element before the 201 is answered, so the very next analysis can cite it. Two more things to know before you send one:

  • Send jurisdictions. A profile whose reviewers cite only their own sources cites no statute, so there is nothing to derive its legal orders from — see above.
  • Every source you create counts against your organisation's limit on legal sources, which GET /v1/quotas answers. The request is weighed as a whole before anything is written: if its entries would take you past the limit it is a 409 and nothing is created.

A retried POST creates a second profile, second reviewers and second sources, as a retried create always has. To change a profile you already have — and add rules to it — use PATCH /v1/profiles/:id, where resending the same snippets entry is recognised rather than duplicated.

A profile can answer on a shortcut

urlSlug is the address the profile opens at, as /p/{urlSlug}. It is optional and free text, and it is what a colleague pastes into a chat rather than a profile id. Two profiles in the same organisation may not hold the same shortcut — the second one is a 409 naming the profile that has it — but two different organisations may.

A profile can be kept out of the pickers

unlisted: true builds a profile that does not appear in the lists your organisation's own people choose from. It still exists, it still runs, and it is still reachable on its /p/{urlSlug} shortcut and through this API — it is simply not offered to everyone. This is the field to send when you are building a profile for one team rather than for the whole organisation.

Two things are worth knowing before you send it. A profile with unlisted: true and no urlSlug can be reached by your integration and by nothing else, which is a perfectly reasonable thing to build and a surprising thing to discover later. And listing is not the same control as publishing: a profile created here is a draft either way until it is published.

It defaults to false, and anything that is not true or false is a 400 rather than a silently ignored value. You can change it afterwards with PATCH /v1/profiles/:id.

A provision has two names, and only one of them works here

Each entry in rules names one provision with three fields — jurisdiction, work and pinpoint. The one to get right is pinpoint, because every provision carries two names:

  • What it is called — 6 kap. 1 §, 7 §, 9 a §. This is the citation a lawyer writes and a report prints. It is not what you send.
  • What it is addressed by — kap_6_par_1§, par_7§, par_9a§. This is the pinpoint.

They are matched exactly, character for character, and nothing translates between them. Sending the citation addresses no provision of that law, and the write is refused naming the reference you wrote — see Every provision must exist inside the statute it cites below.

Take a pinpoint from a response and send it back unchanged. The anchors on GET /v1/snippets/:snippetId carry it in exactly the form this endpoint expects. Never assemble one from a citation, and never adjust the one you were given — work and pinpoint are opaque, and their shape differs per legal order.

Request body

ParameterDescription
name
string·body·required
What the profile is called in the workspace it is created in.
documentType
string·body·required
The kind of document this profile audits, e.g. "LSS". Free text, and what a follow-up action matches on when no profile is named.
workers
Worker[]·body·required
The workers the profile runs, each with a name, an optional instruction and the legal sources it holds. At least one, and at most the organisation's configured limit.A worker takes name, instructions, rules[] and snippetIds[]. Each rules[] entry is { jurisdiction, work, pinpoint }, where pinpoint is the id the provision is addressed by ("par_7§") rather than the label it is printed as ("7 §"). Sending workers[].id is a 400 — this route creates workers rather than adopting existing ones.
snippets
InlineSnippet[]·body
Legal sources this organisation authors in the same request, each named by a ref the workers' snippetIds cite. The server mints each source's id and snippetKey, stores the key in snippetIds in place of the ref, and answers both identities beside the ref.Each entry takes ref plus exactly the body POST /v1/snippets takes (name, source, content, authorityType, validFromYear, validToYear, anchors) and is refused by the same rules, the field named as snippets[<i>].<field>. ref is scoped to this one request and never stored: lower-case letters, digits, _ and -, unique within the request, and every entry must be cited by a workers[].snippetIds value equal to its ref — a snippetIds value equal to a ref always means that entry. At most 75 entries. Every rule set created counts against the organisation's limit on legal sources, checked once for the whole request. A profile whose workers cite only these sources cites no statute, so it must send jurisdictions.
jurisdictions
string[]·body
The legal orders the profile applies, as codes such as SE or US_NH. Each must be open on this platform — the ones your organisation may use are in its settings. Accepted only when no worker cites a statute; otherwise it is derived from the provisions cited and sending it is a 400.
categoryPath
string·body
Where the profile is filed in the workspace's own grouping. Defaults to "Generell".
userHelpText
string·body
The help text shown beside the profile to the people who choose it.
urlSlug
string·body
A shortcut this profile answers on, as /p/{urlSlug}. Unique within your organisation: a shortcut another of your profiles already holds is a 409.
unlisted
boolean·body
Keeps the profile out of the pickers your organisation's own people choose from, leaving it reachable on its /p/{urlSlug} shortcut. Defaults to false, which is a profile that appears everywhere.A profile with no urlSlug and unlisted: true is reachable by the API and by nothing else. Sending anything but true or false is a 400.
outputLanguage
string·body
The language this profile's reports are written in. One of Swedish, Norwegian (Bokmål), Danish and English — the value is the instruction the model reads, so it is the English name of the language and not the language's own name for itself. Omit to inherit the organisation's.A closed set since #820. A value outside it is a 400 naming the accepted values; before that the field was free text, and a client sending "Svenska" had it stored verbatim and reached the model as OUTPUT IN SVENSKA.

What will be refused

Every rule the web application applies applies here, and they are worth knowing before you write the request:

  • A profile must have at least one reviewer. A reviewer is what does the reading, so a profile with none would answer every analysis with nothing at all. Sending workers: [] is a 400 rather than a 201 for an empty audit.
  • A reviewer must hold binding law. A reviewer whose sources are all guidance, general advice or preparatory works cannot substantiate a finding, and the profile is refused naming that reviewer. Which instrument types bind is a property of the jurisdiction, not of this API.
  • Every source must already be in the library, and you can put it there. A provision from a statute we have not ingested is refused naming the work. This endpoint does not fetch one for you — GET /v1/laws/:jurisdiction/:work does, on the spot, so the fix for that refusal is one call and a retry.
  • A rule set is named by its snippetKey, never by its id. GET /v1/snippets publishes both fields on every record, and an analysis looks a rule set up by the key alone — so snippetIds takes keys, in spite of what the field is called. A document id there is a 400 naming each value you sent beside the key that record answers to, rather than a 201 for a profile whose rule sets would be missing from every report it produces without anything saying so.
  • Every provision must exist inside the statute it cites. A pinpoint is matched against the provision's stored address exactly — par_7§, never the 7 § a citation is printed as — and one that addresses nothing is refused naming the reference you wrote. This matters because a reference that resolves to no provision is dropped from the analysis without a word, so the audit would run against less law than you asked for and the report would not say so. Take the pinpoints from GET /v1/laws/:jurisdiction/:work, which lists a law's provisions with the address each answers to — one page at a time, so follow nextCursor until it is null if the pinpoint you want is not in the first page — or check one you already have with GET /v1/snippets?jurisdiction=…&work=…&pinpoint=…, which answers 404 when nothing is addressed.
  • A repealed provision cannot be cited. A repeal leaves the section in the register as a numbered placeholder carrying no text, so a reviewer citing one holds a legal source with nothing in it — and it would pass every check above, because the provision exists and its statute binds. GET /v1/laws/:jurisdiction/:work marks each such provision with isRepealNotice. Only provisions you are adding are refused: a reviewer that already stores one keeps it, so an existing profile can still be edited — and cleaned up.
  • There is a limit on reviewers per profile — three, unless your organisation is configured otherwise.
  • There is a limit on legal sources per reviewer — 25, counting cited provisions and rule sets together. Split a reviewer that needs more into two.
  • There is a limit on how long an instruction may be — 1000 characters, counted as Unicode code points.
  • A source's content has a ceiling — 2,500 characters, counted as Unicode code points. A longer snippets entry is a 400 naming the source, and nothing is written.
  • A source's anchors must exist. Each anchor on a snippets entry addresses a provision of a law we hold, in a legal order that is open, exactly as on POST /v1/snippets; one that addresses nothing is a 400 naming the entry by position and every such anchor.
  • An entry whose address already holds a legal source that belongs to another organisation is a 403; give the entry a different ref.
  • There is a ceiling on how many profiles your organisation may hold, answered by GET /v1/quotas. Creating one past it is a 409; editing the profiles you have is never affected.
  • A value has to have the right shape, not just the right field name. The profile and its reviewers are checked against the stored data contract as the last step before the write, so a snippetIds entry that is not a string — or any other value of a type the table above does not name — is a 400 quoting the field and what was wrong with it, never a 201 with that field quietly dropped.
  • The server owns some fields. id, status, workers[].id and the authorship fields are refused rather than accepted and overwritten — and on a snippets entry id, snippetKey, tenantId, text, clonedFromSystemId and the authorship fields too.
  • A field that is not in the table above is refused, naming it. The same goes for a field on a reviewer or on a snippets entry. Nothing is accepted and quietly ignored: a 201 means every field you sent was stored, so a typo is a 400 you can act on rather than a setting that silently never took effect.
  • A jurisdiction has to be a code, and it has to be open. A code is two upper-case letters with an optional _ suffix — SE, NO, US_NH — matched exactly; se is a 400 naming what you sent and the field it arrived in, whether a cited provision or jurisdictions. A well-formed code is then held to the catalogue: a legal order is opened by the platform's administrators, and one nobody has opened is a 400 naming every such code. There is no list to copy from here — the legal orders your organisation may use are in its settings, and what is open changes without a release.
  • We have to be able to weigh your sources at all. Deciding which of a reviewer's sources bind means reading the norm hierarchy of its legal orders, and when that read fails we refuse rather than guess — because with no hierarchy every source weighs as non-binding, so guessing would tell you to add binding law you already have. Nothing is wrong with your request; retry, and tell us if it repeats.

Nothing is written unless all of it passes: the profile, its reviewers and the sources in snippets are one atomic write.

Which check refused

Every refusal above answers with a stable name in error.details.refusal, and the values that refusal names in error.details.values:

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "The profile names 4 reviewers, which is more than the 3 this organisation allows.",
    "details": {
      "refusal": "too-many-reviewers",
      "values": { "count": 4, "limit": 3 }
    }
  }
}

Branch on details.refusal, never on message. The message is prose written for a person reading it once, and we reword it whenever we can say the same thing better; the name is the contract and does not move. values carries what the sentence interpolates, so you can build your own message without parsing ours.

Several names share one status — too-many-reviewers and record-fails-data-contract are both 400 — which is the whole reason this is here.

details.refusalWhat it means
profile-has-no-reviewerThe profile would be stored with no reviewers at all, so there would be nothing for an analysis to run.
too-many-reviewersThe profile names more reviewers than the organisation's limit allows.
too-many-legal-sourcesOne reviewer holds more legal sources — cited provisions and rule sets counted together — than the organisation's limit allows.
instruction-too-longOne reviewer's instruction is longer than the character limit.
snippet-content-too-longA rule set written with the profile has content longer than the character limit, and that content is not the unchanged text already stored.
organisation-snippet-limit-reachedThe rule sets this request would create would take the organisation past the number of legal sources it may hold.
anchor-not-in-statuteAn anchor on an entry in snippets[] addresses a provision the library does not hold.
reviewer-holds-no-binding-lawOne reviewer holds no source that binds in the profile's legal orders, so it could not substantiate a finding.
norm-hierarchy-unavailableThe norm hierarchy could not be read, so which sources bind could not be determined. A fault on our side rather than a problem with the request, and worth one retry.
jurisdiction-not-openA legal order named in jurisdictions, cited by a reviewer, or anchored by an entry in snippets[], is not open on this platform. Jurisdictions are opened by the platform's administrators, and the ones an organisation may use are in its settings.
legal-source-not-in-libraryA reviewer cites a statute or names a rule set the library does not hold. The API refuses rather than fetching it on demand, which the web application does.
snippet-named-by-document-idA reviewer's `snippetIds` names a rule set by its document `id` rather than by its `snippetKey`. `GET /v1/snippets` publishes both fields side by side and only the key is looked up when an analysis runs, so such a reference used to be stored and the source dropped from every report without a word.
provision-not-in-statuteA `pinpoint` addresses no provision of the work it cites — the refusal #428 added and the reference contradicted for months.
provision-is-repealedA reviewer cites a provision that has been repealed. The register keeps such a section as a numbered placeholder carrying no text, so the citation would give the reviewer a legal source with nothing in it. Only provisions the payload *adds* are refused — one a stored reviewer already holds is left alone, so an old profile stays editable.
jurisdictions-cannot-be-derivedThe profile cites no provision to derive its legal orders from, sent no `jurisdictions`, and the organisation has no default.
shortcut-already-taken`urlSlug` is already held by another profile of the same organisation, which would make the `/p/{slug}` link open the wrong one.
organisation-profile-limit-reachedThe organisation already holds as many profiles as it may, so no further one can be created. Editing the profiles it has is never affected.
record-fails-data-contractThe composed profile or reviewer does not match the stored data contract — a field of the right name carrying a value of the wrong shape.
snippet-owned-by-another-organisationA rule set sent with the profile addresses a stored rule set that belongs to another organisation. A save writes only the organisation's own rule sets.

Errors

ErrorWhen
400
BAD_REQUEST
A required field is missing, a field the server owns was sent (id, status, workers[].id, authorship, and on a snippets[] entry id, snippetKey, tenantId, text, clonedFromSystemId), a field this route does not accept was sent at all, unlisted is neither true nor false, a jurisdiction named anywhere is malformed, a jurisdiction the profile declares or a snippets[] anchor names is not open on this platform, an outputLanguage named is not one this API carries, or a legal source named is not in the library. A snippets[] entry is refused by position: snippets is not an array or carries more than 75 entries, a ref is missing, malformed or repeated, an entry is cited by no worker, a body field is refused as POST /v1/snippets refuses it, an anchor names a provision the library does not hold (anchor-not-in-statute), or content is longer than 2500 characters (snippet-content-too-long). A profile that cites no statute and sends no jurisdictions is here too, naming the field: the legal orders cannot be derived and this organisation has no default.
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 credential belongs to the platform's own workspace and the request carries snippets: the platform's shared catalogue is not written through this API. Not probed: it needs a platform credential.
409
CONFLICT
The organisation already holds as many profiles as it may — GET /v1/quotas says how many — or the urlSlug sent is a shortcut another of its profiles already answers on, or the snippets[] entries would take the organisation past the number of legal sources it may hold (organisation-snippet-limit-reached). All are states of the workspace rather than faults in the request; a missing jurisdictions is a 400 naming the field (#500). Not probed: each needs a tenant put in that state on purpose.
429
TOO_MANY_REQUESTS
The organisation has spent its hourly configuration-write floor. This is an abuse floor and not a plan limit; no analysis quota is consumed. Not probed: reaching it would mean sending sixty writes.
500
INTERNAL_ERROR
An unexpected server-side failure. Not probable from outside — reaching it means something is wrong.

No separate permission to ask for

Every credential of yours may create profiles, and the sources they cite with them. There is nothing to enable and nobody to ask: a credential already reaches everything your organisation is licensed for, and creating a profile through it is the same act as building one in the admin panel. The one 403 here is the platform's own credential sending snippets: its shared catalogue is not written through this API.

What still bounds it is how many profiles your organisation may hold, which GET /v1/quotas answers.

This costs no runs, but it is metered

Writing configuration never spends an analysis from your quota. It does tick an hourly floor against runaway clients, reported by the X-RateLimit-* headers on this route and by GET /v1/quotas. It is set where no human and no scheduled integration reaches it; a 429 here means something of yours is looping.