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. Rule sets of your own that do not exist yet can be written in the same call,
in snippets — see below.
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, 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.
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.
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/quotasanswers. The request is weighed as a whole before anything is written: if its entries would take you past the limit it is a409and 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 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. |
snippetsInlineSnippet[]·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. |
jurisdictionsstring[]·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. |
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 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 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 a law's provisions with the address each answers to — one page at a time, so follownextCursoruntil it isnullif the pinpoint you want is not in the first page — 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, counted as Unicode code points.
- A source's
contenthas a ceiling — 2,500 characters, counted as Unicode code points. A longersnippetsentry is a400naming the source, and nothing is written. - A source's
anchorsmust exist. Each anchor on asnippetsentry addresses a provision of a law we hold, in a legal order that is open, exactly as onPOST /v1/snippets; one that addresses nothing is a400naming 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 differentref. - 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 — and on asnippetsentryid,snippetKey,tenantId,text,clonedFromSystemIdand 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
snippetsentry. Nothing is accepted and quietly ignored: a201means 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 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;seis a400naming what you sent and the field it arrived in, whether a cited provision orjurisdictions. 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 a400naming 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.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. |
snippet-content-too-long | A 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-reached | The rule sets this request would create would take the organisation past the number of legal sources it may hold. |
anchor-not-in-statute | An anchor on an entry in snippets[] addresses a provision the library does not hold. |
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. |
jurisdiction-not-open | A 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-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. |
snippet-owned-by-another-organisation | A 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
| Error | When |
|---|---|
400BAD_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. |
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 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. |
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, 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. |
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, 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.