Create a profile
https://api.granska.cloud/v1/profilesCreates an analysis profile and the workers it runs. The profile is created as a draft.
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.
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§" }
]
}
]
}'{
"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.
The legal orders are derived, not declared
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 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 thepinpoint.
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
| Parameter | Description |
|---|---|
namestring·body·required | What the profile is called in the workspace it is created in. |
documentTypestring·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. |
workersWorker[]·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. |
jurisdictionsstring[]·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. |
categoryPathstring·body | Where the profile is filed in the workspace's own grouping. Defaults to "Generell". |
userHelpTextstring·body | The help text shown beside the profile to the people who choose it. |
urlSlugstring·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. |
unlistedboolean·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. |
outputLanguagestring·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 a400rather than a201for 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/:workdoes, 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 itsid.GET /v1/snippetspublishes both fields on every record, and an analysis looks a rule set up by the key alone — sosnippetIdstakes keys, in spite of what the field is called. A document id there is a400naming each value you sent beside the key that record answers to, rather than a201for 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
pinpointis matched against the provision's stored address exactly —par_7§, never the7 §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 fromGET /v1/laws/:jurisdiction/:work, which lists every provision of a law with the address it answers to, or check one you already have withGET /v1/snippets?jurisdiction=…&work=…&pinpoint=…, which answers404when 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/:workmarks each such provision withisRepealNotice. 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 a409; 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
snippetIdsentry that is not a string — or any other value of a type the table above does not name — is a400quoting the field and what was wrong with it, never a201with that field quietly dropped. - The server owns some fields.
id,status,workers[].idand 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
201means every field you sent was stored, so a typo is a400you can act on rather than a setting that silently never took effect. - A jurisdiction has to be one we carry.
SE,NOorEU, matched exactly. Anything else is a400naming what you sent, wherever you sent it — in a cited provision or injurisdictions. - 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.refusal | What it means |
|---|---|
profile-has-no-reviewer | The profile would be stored with no reviewers at all, so there would be nothing for an analysis to run. |
too-many-reviewers | The profile names more reviewers than the organisation's limit allows. |
too-many-legal-sources | One reviewer holds more legal sources — cited provisions and rule sets counted together — than the organisation's limit allows. |
instruction-too-long | One reviewer's instruction is longer than the character limit. |
reviewer-holds-no-binding-law | One reviewer holds no source that binds in the profile's legal orders, so it could not substantiate a finding. |
norm-hierarchy-unavailable | The 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-library | A 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-id | A 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-statute | A `pinpoint` addresses no provision of the work it cites — the refusal #428 added and the reference contradicted for months. |
provision-is-repealed | A 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-derived | The 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-reached | The 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-contract | The 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
| Error | When |
|---|---|
400BAD_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. |
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. |
409CONFLICT | 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. |
429TOO_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. |
500INTERNAL_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.