Edit a profile
https://api.granska.cloud/v1/profiles/:idEdits a profile this tenant owns, and publishes or unpublishes it. What the request omits keeps the value it had, status included.
What an edit changes
This is the endpoint that changes what your audits actually do. A profile created here arrives as a draft nobody is using yet; an edit lands on a profile people may be running today, and it takes effect on the next analysis that starts. It is also where a draft you built is published — see below.
Send only the fields you want changed. Anything you leave out keeps the value it had, including the
reviewers — omit workers entirely and only the profile record is touched.
The :id in the path is the profile's id as GET /v1/profiles lists it,
which is the same string POST /v1/profiles hands back and the same one
POST /v1/analyze takes. There is one id per profile and it works on every
route. An id your organisation does not hold is a 404, which says nothing about whether it exists
somewhere else.
curl -X PATCH https://api.granska.cloud/v1/profiles/lss_utredning_k4m2 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userHelpText": "Use this profile for investigations decided after 1 January 2026."
}'{
"success": true,
"profileId": "lss_utredning_k4m2",
"workerIds": [
"tenant_9f3a_worker_rattslig_grund_p8x1"
],
"status": "PUBLISHED"
}Publishing, and staying published
status is what decides whether your organisation's own people are offered the profile in their
list. Send "PUBLISHED" to publish it and "DRAFT" to take it back to a draft. This is the call
that finishes a profile you built over the API: nobody has to open the admin panel for it.
Omit status and the stored one stands. An edit that renames a published profile leaves it
published, and one that reworks a draft leaves it a draft. That is deliberate, and it is the reason
the field has to be sent rather than derived: the alternative — taking the profile out of
circulation on every edit — made profiles disappear from people's lists with nothing to say why.
It follows that a half-finished wording reaches everyone using a published profile the moment you
save it. If that is not what you want, take the profile back to "DRAFT" first, edit, and publish
again.
Only "DRAFT" and "PUBLISHED" are accepted; anything else is a 400 naming them, rather than a
200 for a profile that did not move. Creating a profile does not take
this field at all — a profile is created as a draft and published here afterwards.
Sending workers replaces the whole set
workers is not merged reviewer by reviewer. The array you send is the profile's reviewers
afterwards:
- A reviewer with an
idyou already have is edited in place. - A reviewer with no
idis created. - A reviewer you leave out is no longer part of this profile. It is not deleted — if another profile names it, that profile is unaffected.
A reviewer can belong to more than one profile. GET /v1/profiles
marks such a reviewer with "shared": true, and editing it here changes every profile that names
it — including profiles this request never mentioned. Read the list before you edit if you did not
build the profile yourself.
Sending rules on a reviewer replaces its legal sources the same way. A pinpoint is the id the
provision is addressed by (kap_6_par_1§), never the citation it is printed as (6 kap. 1 §), and
sending the wrong one is refused naming the reference you wrote —
creating a profile
explains where to get the right one.
Changing the shortcut
urlSlug is the address the profile opens at, as /p/{urlSlug}. Omit it and the stored one stands;
send a new one to move the profile to a new address, or send "" to take the shortcut away. Anyone
holding the old link lands on nothing afterwards, so treat it as you would any published URL.
Hiding a profile, and putting it back
unlisted: true takes the profile out of the lists your organisation's own people choose from,
leaving it reachable on its /p/{urlSlug} shortcut and through this API. unlisted: false puts it
back. Omit the field and the stored setting stands — an edit that renames a hidden profile leaves it
hidden.
Anything that is not true or false is a 400. That matters more here than on a create: a value
we could not read would look exactly like a field you never sent, so you would be answered 200 by
a profile that is still hidden.
Listing is not publishing. A profile can be published and unlisted at the same time, which is precisely the profile that answers on its shortcut and appears in nobody's picker. The two fields are set independently, and sending one says nothing about the other.
Request body
| Parameter | Description |
|---|---|
namestring·body | A new name for the profile. Omit to keep the stored one. |
documentTypestring·body | A new document type. Omit to keep the stored one. |
workersWorker[]·body | Replaces the whole set of workers this profile runs. Omit it and the workers are left untouched.Send workers[].id to edit an existing worker, or omit it to create one. 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 §"). A worker may be shared with another profile — GET /v1/profiles marks it — and editing a shared worker changes every profile that names it. |
categoryPathstring·body | Where the profile is filed in the workspace's own grouping. |
userHelpTextstring·body | The help text shown beside the profile to the people who choose it. |
urlSlugstring·body | A new shortcut this profile answers on, as /p/{urlSlug}. Omit to keep the stored one; send "" to remove it. Unique within your organisation: a shortcut another of your profiles already holds is a 409. |
unlistedboolean·body | Whether the profile is kept out of the pickers your organisation's own people choose from. Send true to hide it, false to list it again. Omit to keep the stored setting.Sending anything but true or false is a 400 — a value the route cannot read would otherwise leave the profile as visible as it was while the answer said 200. |
outputLanguagestring·body | The language this profile's reports are written in — one of Swedish, Norwegian (Bokmål), Danish. Omit to keep the stored setting.A closed set since #820. A value outside it is a 400 naming the accepted values. |
statusstring·body | Whether the profile is offered to your organisation's own people. Send "PUBLISHED" to publish it, "DRAFT" to take it back to a draft. Omit it and the stored status stands — an edit that says nothing about status leaves a published profile published.Only "DRAFT" and "PUBLISHED" are accepted; anything else is a 400 naming them. Publishing is not listing: a published profile with unlisted: true is still kept out of the pickers. POST /v1/profiles does not take this field — a profile is created as a draft and published here afterwards. |
An analysis already running uses the edited profile
Analyses are not frozen at the moment you start them. The profile is read when the analysis actually
runs, which can be seconds or minutes after
POST /v1/analyze answered — so an analysis queued before your edit will run
under the profile as it is after it.
Usually that is invisible and harmless. Two edits will fail such an analysis outright: removing a
reviewer it was going to run, and pushing the profile past your organisation's reviewer limit. The
job then comes back from GET /v1/jobs/:jobId as FAILED with
"errorCategory": "INVALID_CONFIGURATION", which is the one failure category that says the fault is
in the configuration rather than in our systems — a retry of the same document will fail the same
way until the profile is fixed.
If your integration edits profiles on a schedule, the safe habit is to edit when nothing of yours is in flight. Nothing stops you doing otherwise, and nothing ever silently changes an analysis you have already been given a result for.
What will be refused
-
An id that names no profile of yours. A
404, and the id to check it against is the oneGET /v1/profileslists — the same stringPOST /v1/analyzetakes. -
A profile your organisation does not own. Profiles you inherit — the ones you did not build — are read-only here. A
403says so; copying one into your own organisation is a separate step. -
The same rules a create is held to. Every reviewer must hold binding law, every source must already be in the library, and the limits on reviewers, sources and instruction length apply unchanged. See creating a profile for what each of those means. One thing an edit adds: a reviewer that already holds more sources than the limit allows keeps what it has and may not be given more, so the allowance only ever ratchets down.
-
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. Worth reading twice on an edit:workersreplaces what is stored rather than merging into it, so this is the write that decides which sources your reviewers hold from here on. -
The server owns some fields.
idcomes from the path,statusnever moves on an edit, the legal orders follow the provisions your reviewers cite, and the authorship fields are ours. All four are refused rather than accepted and overwritten. -
A jurisdiction has to be one we carry.
SE,NOorEU, matched exactly —seis notSE. Anything else is a400naming what you sent. -
A shortcut another of your profiles holds.
urlSlugis unique within your organisation, and taking one already in use is a409naming the profile that has it. -
A field that is not in the table above is refused, naming it. The same goes for a field on a reviewer. This matters more on an edit than anywhere else: a
200means every field you sent replaced what was stored, so a misspelled field name is a400rather than an edit you believe you made. -
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. Nothing is wrong with your request; retry, and tell us if it repeats.
Nothing is written unless all of it passes.
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": "NOT_FOUND",
"message": "No profile of that id exists in this workspace.",
"details": {
"refusal": "profile-not-found",
"values": {}
}
}
}
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.
The table below is every refusal an edit can answer with, the ones it shares with a create included.
Two of them only an edit reaches: profile-not-found and profile-owned-by-another-organisation.
| 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. |
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. |
profile-not-found | No profile of that id exists. |
profile-owned-by-another-organisation | The profile exists but belongs to another organisation — an inherited profile is read-only until it is copied. |
Errors
| Error | When |
|---|---|
400BAD_REQUEST | A field the server owns was sent (id, jurisdictions, authorship), workers is not an array, unlisted is neither true nor false, status is neither DRAFT nor PUBLISHED, 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. Not probed: every spelling of it needs a body, and NOT_FOUND below proves the same route with none. |
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 profile belongs to another workspace — an inherited SYSTEM profile is read-only here and has to be copied first (#301). Not probed: it needs an id the verifying tenant can see but does not own. |
404NOT_FOUND | No profile with that id in this workspace. |
409CONFLICT | The edit leaves the profile with no determinable legal order, or the urlSlug sent is a shortcut another of the organisation's profiles already answers on. Not probed: the first needs a tenant configured without a default one, the second a shortcut already taken. |
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 edit the profiles your organisation owns. A 403 here means something
else: the profile belongs to another workspace — an inherited one — and has to be copied into yours
before it can be changed.
This costs no runs, but it is metered
Editing configuration never spends an analysis from your quota. It does tick the same hourly floor as
creating one — a guard against a client in a loop, reported by the X-RateLimit-* headers on this
route and by GET /v1/quotas. An integration that syncs its profiles on a
schedule will never see it; a 429 here means something of yours is looping.