Skip to main content

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 -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" }'

The role applies on the entity named by X-Scope. Remove a role with DELETE /users/v1/{id}/roles/{roleKey}.

You can only grant what you hold

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:

Statuserror.codeMeans
403forbiddenYou lack members.manage, or the role you're granting exceeds your own — the escalation guard above
404not_foundNo 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
409conflictThey already hold that role. Safe to treat as success if you were making sure
422validation_failedThe 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."
}'
FieldRequiredNotes
member_typeyesWhat 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_emailfor a userEither this or member_id. An email that has no account yet provisions one on accept
member_idfor non-usersRequired when member_type is not user — you are linking an entity that already exists, so there is nothing to invite by email
requested_role_keysyes¹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
messagenoFree text shown to the invitee, up to 2000 characters
member_profilenoname, 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:

InviteeEndpointAuthBody
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 tokennone
Brand new, no credential at allPOST /invitations/{token}/acceptNone — holding a valid, unexpired token is the authorizationpassword + 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​