Users, Roles & Permissions
This guide covers the practical side of access control: listing users, assigning roles on an entity, checking what a user can actually do, and handling invitations. For the model behind it — the four layers that decide every request — read the Permissions concept first, and see The User and The Team for those entities' object references.
Users live under /users/v1; effective-permission checks live under
/entitypermissions/v1. Both are scoped.
List and read users
curl "https://api.klart.com/users/v1?perPage=25" \
-H "Authorization: Bearer $KLART_TOKEN" -H "X-Scope: 42"
GET /users/v1/{id} returns one user, including their membership and roles
on the current scope:
{
"data": {
"id": "01a0e7ad-c7cf-…",
"name": "Ola Hansen",
"firstName": "Ola",
"lastName": "Hansen",
"email": "ola@firm.no",
"phone": null,
"avatarUrl": null,
"createdAt": "2026-07-02T09:14:00Z",
"updatedAt": "2026-09-28T11:02:00Z",
"membership": {
"id": "01a0e7ad-d98a-…",
"status": "active",
"invitedAt": "2026-07-01T15:40:00Z",
"acceptedAt": "2026-07-02T09:14:00Z",
"suspendedAt": null
},
"roles": [
{
"id": "01a0e7ad-dd19-…",
"key": "case_editor",
"name": "Case Editor",
"assignedAt": "2026-07-02T09:20:00Z",
"assignedBy": "01a0e7ad-c440-…"
}
]
},
"meta": { "requestId": "d4e2…" }
}
membership describes the user's standing in the current scope, not which
scope it is — the scope is the one you sent in X-Scope. A status other than
active (or a non-null suspendedAt) means the person is still listed but cannot
act. Optional fields are always present rather than omitted, so you can read them
without existence checks.
Assign a role
Roles are granted per entity/scope — the same user can be an editor here and
a viewer there. Assign one with its role_key:
- curl
- Python
- JavaScript
curl -X POST "https://api.klart.com/users/v1/01a0e7ad-c7cf-…/roles" \
-H "Authorization: Bearer $KLART_TOKEN" \
-H "X-Scope: 42" \
-H "Content-Type: application/json" \
-d '{ "role_key": "case_editor" }'
import os, requests
resp = requests.post(
"https://api.klart.com/users/v1/01a0e7ad-c7cf-…/roles",
headers={
"Authorization": f"Bearer {os.environ['KLART_TOKEN']}",
"X-Scope": "42",
},
json={"role_key": "case_editor"},
)
resp.raise_for_status()
user = resp.json()["data"] # returns the updated user with roles[]
const resp = await fetch("https://api.klart.com/users/v1/01a0e7ad-c7cf-…/roles", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KLART_TOKEN}`,
"X-Scope": "42",
"Content-Type": "application/json",
},
body: JSON.stringify({ role_key: "case_editor" }),
});
const user = (await resp.json()).data;
The role applies on the entity named by X-Scope. Remove a role with DELETE /users/v1/{id}/roles/{roleKey}.
Assigning roles is itself permission-gated: you can't grant a capability your own
role doesn't include. Attempting to escalate returns 403 forbidden.
The assignment call has four distinct failure answers, and they mean different
things — branch on error.code, not on the status alone:
| Status | error.code | Means |
|---|---|---|
403 | forbidden | You lack members.manage, or the role you're granting exceeds your own — the escalation guard above |
404 | not_found | No such user, or the user is not a member of the scope in X-Scope. Add them to the scope first; you cannot assign a role to a stranger |
409 | conflict | They already hold that role. Safe to treat as success if you were making sure |
422 | validation_failed | The role_key is not assignable here — unknown, or not grantable at this entity type |
Check what a user can actually do
Rather than assign a role and hope, ask the API for a user's effective permissions on a specific entity — the resolved answer after all four layers:
curl "https://api.klart.com/entitypermissions/v1/entities/case/01a0e7ad-c440-…/users/01a0e7ad-c7cf-…/effective-permissions" \
-H "Authorization: Bearer $KLART_TOKEN" -H "X-Scope: 42"
This is the authoritative "would this action be allowed?" check — use it to drive UI (hide buttons a user can't use) instead of trial-and-error calls.
Scope profile
A user's per-scope profile (display fields specific to a scope) is read and updated with:
GET /users/v1/{id}/scope-profile
PUT /users/v1/{id}/scope-profile
Invitations
Invitations let people join an entity. There are two sides: you invite someone into an entity you administer, and they accept from their own inbox.
Inviting someone
POST /entitymanagement/v1/{entityType}/{entityId}/invitations — the shared entity
sub-resource (see Entities).
This is the one call that both creates the membership-to-be and decides what the
person will be able to do once they accept.
curl -X POST "https://api.klart.com/entitymanagement/v1/case/01a0e7ad-c440-…/invitations" \
-H "Authorization: Bearer $KLART_TOKEN" \
-H "X-Scope: 42" \
-H "Content-Type: application/json" \
-d '{
"member_type": "user",
"member_email": "ola@example.com",
"requested_role_keys": ["case_editor", "billing"],
"message": "Joining you to the Nordvik matter."
}'
| Field | Required | Notes |
|---|---|---|
member_type | yes | What kind of member joins. Which values are legal depends on the host entity: tenant accepts user and tenant; project accepts user and group; group, client and case accept user only |
member_email | for a user | Either this or member_id. An email that has no account yet provisions one on accept |
member_id | for non-users | Required when member_type is not user — you are linking an entity that already exists, so there is nothing to invite by email |
requested_role_keys | yes¹ | An array — an invitation can carry more than one role. At least one entry |
requested_role_key | — | Deprecated singular form, still accepted during the deprecation window. Send exactly one of the two, never both |
message | no | Free text shown to the invitee, up to 2000 characters |
member_profile | no | name, first_name, last_name, phone — applied only when the invitation provisions a new user. Rejected with 422 for any member_type other than user, deliberately: those are columns on the users table, and silently dropping them would look like a save that worked |
¹ Neither role field is individually required, but the request is rejected if both are absent — an invitation with no role would validate and then fail deeper in.
The response is the invitation record. It carries requestedRoleKeys (the
array) alongside a deprecated requestedRoleKey that reports only the first
role — so a client reading the singular field on a multi-role invitation silently
sees an incomplete picture. Read the array.
Removing a pending invitation is DELETE /entitymanagement/v1/{entityType}/{entityId}/invitations/{id}; re-inviting the
same person afterwards is allowed.
Accepting one
The invited user manages their own invitations under /users/v1/me:
# What am I invited to?
curl "https://api.klart.com/users/v1/me/invitations" \
-H "Authorization: Bearer $KLART_TOKEN"
# Accept
curl -X POST "https://api.klart.com/users/v1/me/invitations/{token}/accept" \
-H "Authorization: Bearer $KLART_TOKEN"
/decline rejects it. These me endpoints act on the authenticated user, so
they don't need an X-Scope.
There are two accept endpoints, and which one applies depends on whether the invitee already has an account. Sending someone to the wrong one is the usual integration mistake here:
| Invitee | Endpoint | Auth | Body |
|---|---|---|---|
| Already has any way to sign in — a password or an OAuth credential (Google/Microsoft) | POST /users/v1/me/invitations/{token}/accept (shown above) | Bearer token | none |
| Brand new, no credential at all | POST /invitations/{token}/accept | None — holding a valid, unexpired token is the authorization | password + password_confirmation, optional name |
The public endpoint answers 409 with requires_login: true for anyone who
already has a credential, so a client can send them to sign in and accept from
their inbox instead. Note "existing" deliberately includes OAuth-only
users — checking only for a local password would force a password onto someone who
signs in with Google. The password policy on the new-user path is the same one
password reset enforces.
GET /invitations/{token} previews an invitation without authentication, so an
accept page can show what is being offered before asking for anything:
{
"entity_name": "Nordvik matter",
"roles": ["Case Editor", "Billing"],
"role_key": "case_editor",
"email": "ola@example.com",
"requires_password": true
}
roles carries the human-readable display name of every requested role — show
this. role_key is the back-compat singular, carrying the raw key of the first role
only; it is the same trap as requestedRoleKey above. requires_password tells you
which of the two accept endpoints applies, so you don't have to probe for the 409.
Note this endpoint is not in the OpenAPI reference: it is served from the web stack
rather than the versioned API, so it carries no /{service}/v1 prefix and Scramble
does not see it.
Both accept routes are rate-limited on their own invitations.accept bucket.
Where to go next
- Permissions concept — the four-layer model and
the
403vs404distinction - Working with Clients & Cases — the entities these roles apply to
- API Reference → Access & Permissions