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.

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. Store that one string and use it for all three.

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.

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 called6 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 bykap_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.
jurisdictions
string[]·body
The legal orders the profile applies. 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 — 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 every provision of a law with the address it answers to, 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.
  • 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.
  • A field that is not in the table above is refused, naming it. The same goes for a field on a reviewer. 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 one we carry. SE, NO or EU, matched exactly. Anything else is a 400 naming what you sent, wherever you sent it — in a cited provision or in jurisdictions.
  • 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 and its reviewers 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.
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.
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.

Errors

ErrorWhen
400
BAD_REQUEST
A required field is missing, a field the server owns was sent (id, status, workers[].id, authorship), a field this route does not accept was sent at all, unlisted is neither true nor false, a jurisdiction named is not one this API carries, an outputLanguage named is not one this API carries, or a legal source named is not in the library. 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.
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. Both are states of the workspace rather than faults in the request; a missing jurisdictions is a 400 naming the field (#500). Not probed: both need 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. 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.

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.