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 — and the rule sets it cited stay stored, still
counted against your organisation's limit on legal sources whether or not anything cites them.
That includes sources you created with
snippetsbelow.
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.
Adding your own rules in the same edit
snippets creates rule sets of your own together with the reviewers that cite them, exactly as on
creating a profile. It
travels only with workers — every entry must be cited by a reviewer, and only workers names
any — and since workers replaces the whole set, send every reviewer the profile should keep, not
only the one citing the new rule:
{
"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"] }
]
}
No jurisdictions here: an edit refuses it, and the profile's stored legal orders stand.
ref is a label for this one request and is never stored; a reviewer cites the entry by putting the
same string in snippetIds, and the server stores the key it mints in its place. The answer carries
snippets, each entry's ref beside the id and snippetKey of the source — snippetKey for
snippetIds, id for PATCH /v1/snippets/:id.
Resending the same snippets creates no second source. The address of a source written here
follows from the profile and the entry's ref, so an integration that syncs its profiles on a
schedule can resend its snippets every time: an entry identical to the source this profile already
created under that ref writes nothing and is cited by the key it already has.
That holds for sources only. A reviewer with no id is created, so resending the example above
unchanged creates a second reviewer and leaves the first one stored outside the profile. After the
first edit, send each reviewer with the id the answer's workerIds gave it.
This endpoint never edits a stored source. An entry that reuses a ref this profile already
created a source under, with anything different in it, is a 409 naming the stored id: change the
source with PATCH /v1/snippets/:id, or give the entry a new ref.
An entry whose address already holds a legal source that belongs to another organisation is a
403; give the entry a different ref.
Two consequences follow, and neither is refused. Renaming a ref creates a new source and leaves
the old one stored, as a reviewer you drop leaves its sources. And 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, and one that would take you
past the limit is a 409 that creates nothing. An identical resend creates nothing and counts
nothing.
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. |
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. Sent only together with workers, which replaces the whole set: a worker the request omits leaves this profile, and the sources it cited stay stored. A ref this profile already created a source under is recognised: resent unchanged it writes nothing and is cited by its stored snippetKey; changed, it is a 409 — edit the source with PATCH /v1/snippets/:id, or give the entry a new ref. Renaming a ref leaves the old source stored. |
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 and English. 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. -
A change to a reviewer we own. Your profile may name one of ours, and naming it is all this endpoint lets you do with it: sent back as it stands, the profile keeps pointing at our record and keeps receiving our improvements to it. Changing any of its four writable fields is an edit of a record every other organisation runs, and is a
403naming the reviewer, including when theidnames a template's own record — and since this endpoint replaces the whole reviewer, omitting a field is a change to it unless it is empty already. To run your own version, send it as a new reviewer with noid; it is created under your organisation and stands beside ours. -
The same rules a create is held to. Every reviewer must hold binding law, every source must already be in the library — or be written in this request, in
snippets— 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. -
A
snippetsentry is held to the rulesPOST /v1/snippetsholds a source to, the field named with its position. Itscontentmay be at most 2,500 characters, counted as Unicode code points, and a longer one is a400naming the source. Each of itsanchorsmust address a provision of a law we hold, in a legal order that is open; one that addresses nothing is a400naming the entry by position. -
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, and on asnippetsentry so areid,snippetKey,tenantId,textandclonedFromSystemId. -
A jurisdiction has to be a code, and it has to be open. Two upper-case letters with an optional
_suffix —SE,US_NH— matched exactly;seis notSEand is a400naming what you sent. A well-formed code nobody has opened is a400naming it; the legal orders your organisation may use are in its settings. -
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: 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": "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. |
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. |
snippet-ref-holds-another-text | An entry in snippets[] reuses a ref this profile already created a rule set under, with a different body. The profile routes never edit a stored rule set. |
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. |
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. |
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. |
inherited-record-readonly | The write would land a private copy on an inherited record's own logical id, which replaces the original for that workspace instead of standing beside it. An inherited profile or reviewer is read-only; a reviewer the platform owns may be named as it stands, but not edited. |
Errors
| Error | When |
|---|---|
400BAD_REQUEST | A field the server owns was sent (id, jurisdictions, authorship, and on a snippets[] entry id, snippetKey, tenantId, text, clonedFromSystemId), workers is not an array, unlisted is neither true nor false, status is neither DRAFT nor PUBLISHED, 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. snippets sent without workers is here, and a snippets[] entry is refused by position as on POST /v1/profiles: not an array or more than 75 entries, a ref missing, malformed or repeated, an entry cited by no worker, a body field POST /v1/snippets would refuse, an anchor the library does not hold (anchor-not-in-statute), or content longer than 2500 characters (snippet-content-too-long). 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) — or the credential belongs to the platform's own workspace and the request carries snippets, since the platform's shared catalogue is not written through this API. Not probed: the first needs an id the verifying tenant can see but does not own, the second a platform credential. |
404NOT_FOUND | No profile with that id in this workspace. |
409CONFLICT | The edit leaves the profile with no determinable legal order, the urlSlug sent is a shortcut another of the organisation's profiles already answers on, a snippets[] entry reuses a ref this profile already created a source under with a different body (snippet-ref-holds-another-text — edit it with PATCH /v1/snippets/:id, or give the entry a new ref), or the entries would take the organisation past the number of legal sources it may hold (organisation-snippet-limit-reached). Not probed: each needs a workspace 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 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. Or the credential is the platform's own and the request carries
snippets: its shared catalogue is not written through this API.
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.