GRANSKA

Edit a profile

PATCHhttps://api.granska.cloud/v1/profiles/:id

Edits a profile this tenant owns, and publishes or unpublishes it. What the request omits keeps the value it had, status included.

Bearer tokenSpends no quotaAnswers with the rate-limit headers

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.

Request
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."
  }'
Response
{
  "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 id you already have is edited in place.
  • A reviewer with no id is 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 snippets below.

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

ParameterDescription
name
string·body
A new name for the profile. Omit to keep the stored one.
documentType
string·body
A new document type. Omit to keep the stored one.
workers
Worker[]·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.
snippets
InlineSnippet[]·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.
categoryPath
string·body
Where the profile is filed in the workspace's own grouping.
userHelpText
string·body
The help text shown beside the profile to the people who choose it.
urlSlug
string·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.
unlisted
boolean·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.
outputLanguage
string·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.
status
string·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 one GET /v1/profiles lists — the same string POST /v1/analyze takes.

  • A profile your organisation does not own. Profiles you inherit — the ones you did not build — are read-only here. A 403 says 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 403 naming the reviewer, including when the id names 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 no id; 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 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. Worth reading twice on an edit: workers replaces what is stored rather than merging into it, so this is the write that decides which sources your reviewers hold from here on.

  • A snippets entry is held to the rules POST /v1/snippets holds a source to, the field named with its position. Its content may be at most 2,500 characters, counted as Unicode code points, and a longer one is a 400 naming the source. Each of its anchors must address a provision of a law we hold, in a legal order that is open; one that addresses nothing is a 400 naming the entry by position.

  • The server owns some fields. id comes from the path, status never 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 a snippets entry so are id, snippetKey, tenantId, text and clonedFromSystemId.

  • 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; se is not SE and is a 400 naming what you sent. A well-formed code nobody has opened is a 400 naming it; the legal orders your organisation may use are in its settings.

  • A shortcut another of your profiles holds. urlSlug is unique within your organisation, and taking one already in use is a 409 naming 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 200 means every field you sent replaced what was stored, so a misspelled field name is a 400 rather 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.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.
snippet-content-too-longA 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-reachedThe rule sets this request would create would take the organisation past the number of legal sources it may hold.
snippet-ref-holds-another-textAn 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-statuteAn anchor on an entry in snippets[] addresses a provision the library does not hold.
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.
jurisdiction-not-openA 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-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.
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.
profile-not-foundNo profile of that id exists.
profile-owned-by-another-organisationThe profile exists but belongs to another organisation — an inherited profile is read-only until it is copied.
snippet-owned-by-another-organisationA 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-readonlyThe 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

ErrorWhen
400
BAD_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.
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.
403
FORBIDDEN
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.
404
NOT_FOUND
No profile with that id in this workspace.
409
CONFLICT
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.
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 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.