Team, roles, and invitations
Who works at the organization, what each person may see, and how new people are brought in.
There are two vocabularies here and they do different jobs.
- A role is organization-wide:
Member,Admin, orOwner. It decides what somebody may do to the organization itself, its billing, and its people. - A grant is per client company: a list of permissions on one profile. It decides what somebody may do to that client's documents, sources, reminders, and settings.
A person needs both. An Admin with no grants can manage the team and see nothing about any client's
invoices. See Authentication for the full ladder and
Profiles for what a client company is.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/members |
Admin |
| GET | /v1/members/{memberId}/grants |
Admin |
| PUT | /v1/members/{memberId}/grants |
Admin |
| PUT | /v1/members/{memberId}/role |
Owner |
| DELETE | /v1/members/{memberId} |
Admin |
| POST | /v1/members/{memberId}/transfer-ownership |
Owner |
| GET | /v1/invitations |
Admin |
| POST | /v1/invitations |
Admin |
| DELETE | /v1/invitations/{invitationId} |
Admin |
| POST | /v1/invitations/{invitationId}/resend |
Admin |
| GET | /v1/me/invitations |
any |
| POST | /v1/me/invitations/{invitationId}/accept |
any |
| POST | /v1/me/invitations/{invitationId}/decline |
any |
The first ten are organization-scoped and need X-Organization-Id. The three under /v1/me are not,
because an invitation is addressed to a person before they belong to the organization that sent it.
See Authentication.
The role ladder
| Value | Name | What it carries |
|---|---|---|
0 |
Member |
Only what their grants say. No team routes, no billing, no organization settings |
1 |
Admin |
Everything a Member has, plus the team, invitations, sources, and reading billing |
2 |
Owner |
Everything, plus changing the subscription and changing roles |
Roles serialize as integers. See Conventions.
There is exactly one Owner. It is the person who created the organization or the person it was handed to, and the seat does not split. That is why a role change and a transfer are separate routes.
One rule runs through every write on this page: you may only act on a role below your own. An
Admin may invite and remove Members and other Admins but may not touch the Owner, may not hand out
the Owner role, and may not change their own grants. Breaking it answers 403 with
member_role_not_below_caller, member_role_not_allowed, member_owner_protected,
member_self_grant_change, or invitation_role_not_allowed, depending on which write asked. See
Error codes.
GET /v1/members
Everybody in the organization. Admin. Answers a plain array, not a page.
| Field | Type | Means |
|---|---|---|
id |
uuid | The membership. This is the memberId in the routes below |
userId |
uuid | The person behind it |
email |
string | Their sign-in address |
displayName |
string or null | Their name, when they have set one |
avatarUrl |
string or null | Full-size picture |
avatarThumbnailUrl |
string or null | Small picture for a roster row |
role |
OrganizationRole |
0 Member, 1 Admin, 2 Owner |
grantedProfileCount |
integer | How many clients they have a grant on |
hasOrganizationWideAccess |
boolean | True when their role reaches every client without grants |
createdAt |
date-time | When they joined |
memberId is the membership id, not the user id. The same person can belong to two organizations and
has a different memberId in each. Address these routes with id, never with userId.
hasOrganizationWideAccess is the field to read before you read grantedProfileCount. When it is
true, a grantedProfileCount of 0 does not mean the person sees nothing; it means they do not need
grants. Showing "0 clients" next to an Owner is the classic bug here.
[
{
"id": "33333333-3333-4333-8333-333333333333",
"userId": "44444444-4444-4444-8444-444444444444",
"email": "ana@example.com",
"displayName": "Ana Example",
"avatarUrl": null,
"avatarThumbnailUrl": null,
"role": 1,
"grantedProfileCount": 12,
"hasOrganizationWideAccess": false,
"createdAt": "2026-09-01T08:00:00Z"
}
]
GET /v1/members/{memberId}/grants
One person's access grid. Admin.
| Field | Type | Means |
|---|---|---|
memberId |
uuid | The membership |
userId |
uuid | The person |
role |
OrganizationRole |
Their role |
hasOrganizationWideAccess |
boolean | True when the role already reaches every client |
grants |
array | One entry per client they hold permissions on |
Each entry in grants:
| Field | Type | Means |
|---|---|---|
profileId |
uuid | The client company |
permissions |
array | ProfilePermission values, as integers |
| Value | Name | Lets them |
|---|---|---|
0 |
View |
See the client, its documents, and its invoices |
1 |
ManageDocuments |
Upload, confirm, correct, and trash documents |
2 |
ManageSources |
Connect and disconnect email, e-Factura, and connectors |
3 |
SendReminders |
Send and stop chasing |
4 |
ManageSettings |
Change the client's own settings |
The permissions are a set, not a ladder. ManageDocuments does not imply View, so a grant that is
meant to let somebody work on a client usually lists View alongside whatever else it lists. Grant
what the job needs and read the list literally.
When hasOrganizationWideAccess is true, grants is usually empty and it does not limit anything.
PUT /v1/members/{memberId}/grants
Replaces the whole grid. Admin.
{
"grants": [
{ "profileId": "22222222-2222-4222-8222-222222222222", "permissions": [0, 1, 3] },
{ "profileId": "55555555-5555-4555-8555-555555555555", "permissions": [0] }
]
}
Answers 200 with the same shape GET returns.
This is not a patch. Every client missing from the body loses its grant, and sending {"grants": []}
removes all of them. To add one client, read the grid, append, and send the whole thing back. A
client-side screen that sends only the row somebody just edited will silently revoke the rest.
Refusals:
| Code | Status | Means |
|---|---|---|
member_self_grant_change |
403 | An Admin editing their own grants |
member_role_not_below_caller |
403 | Editing somebody at your level or above |
An Admin may not widen their own access, which is why the first one exists. Somebody else with the authority has to do it.
PUT /v1/members/{memberId}/role
Changes a role. Owner only.
{ "role": 1 }
Answers 200 with the member. Member and Admin are the only values that go through here.
Promoting somebody to Owner answers 403 member_role_not_allowed, because there is one Owner and
handing the seat over is a different route that also
moves the caller out of it.
Demoting the Owner answers 403 member_owner_protected. An organization is never left without one.
Grants are untouched by a role change. Promoting a Member to Admin does not widen what they see of any client; it widens what they may do to the organization.
DELETE /v1/members/{memberId}
Removes somebody from the organization. Admin. Answers 204.
Their grants go with them, and they lose the organization from their switcher immediately. Nothing they collected, confirmed, or sent is deleted or reattributed. The audit trail keeps their name against what they did.
Removing the Owner answers 403 member_owner_protected. Transfer the seat first, then remove the
person.
Removing somebody at or above your own role answers 403 member_role_not_below_caller.
POST /v1/members/{memberId}/transfer-ownership
Hands the Owner seat to another member. Owner. No body.
Answers 200 with the member who is now the Owner.
The caller becomes an Admin in the same transaction. There is no moment with two Owners and no moment with none. Expect the caller's own role to have changed when the call returns, and refresh whatever you cached about it. Anything the caller could do as Owner, changing the subscription above all, they cannot do on the next request.
This is the only way the Owner seat moves. It is deliberately a separate route from a role change, because it is the one write in this area that takes something away from the person making it.
Invitations
Somebody who does not have a Syllevo account yet cannot be added; they have to be invited. An invitation is an email plus the role and grants they will get when they accept.
An invitation lives 14 days. Past that, accepting it answers 409 invitation_expired and the
only fix is a resend, which puts the clock back to a full 14 days.
| Value | Name | Means |
|---|---|---|
0 |
Pending |
Sent, not yet answered, may still be live |
1 |
Accepted |
They joined |
2 |
Declined |
They said no |
3 |
Revoked |
The firm took it back |
status and expired answer different questions. A row can be Pending and expired: true at the
same time: nobody answered it and the 14 days ran out. Read expired before you show "waiting for a
reply".
GET /v1/invitations
Every invitation this organization has sent, in any state. Admin. A plain array.
| Field | Type | Means |
|---|---|---|
id |
uuid | The invitation |
email |
string | Who it went to |
role |
OrganizationRole |
What they will be |
status |
InvitationStatus |
0 pending, 1 accepted, 2 declined, 3 revoked |
expired |
boolean | Whether the 14 days have run out |
expiresAt |
date-time | When they do |
lastSentAt |
date-time, null | When the email last went out |
emailSent |
boolean | Whether the last send reached the mail relay |
invitedByName |
string or null | Who invited them |
grantedProfileCount |
integer | How many clients the invitation carries |
hasOrganizationWideAccess |
boolean | Whether the role reaches every client anyway |
createdAt |
date-time | When it was created |
emailSent: false with a 201 is not a contradiction. The invitation exists and the link works; the
mail relay is what did not take it. Show a resend button rather than an error, because the row is
good.
POST /v1/invitations
Creates and sends one. Admin. Answers 201.
| Field | Type | Required | Means |
|---|---|---|---|
email |
string | yes | Where to send it |
role |
OrganizationRole |
yes | 0 Member or 1 Admin |
grants |
array | no | The same {profileId, permissions} shape as grants |
{
"email": "mihai@example.com",
"role": 0,
"grants": [{ "profileId": "22222222-2222-4222-8222-222222222222", "permissions": [0, 1] }]
}
The grants ride with the invitation and are applied the moment it is accepted, so a new person lands with the right clients already visible instead of waiting for a second setup step.
Refusals:
| Code | Status | Means |
|---|---|---|
invitation_role_not_allowed |
403 | Inviting somebody at or above your own role, Owner included |
invitation_already_member |
409 | That address already belongs to this organization |
DELETE /v1/invitations/{invitationId}
Revokes a pending invitation. Admin. Answers 204. The link stops working.
The row is not deleted, it moves to status 3. Who invited whom is part of the record.
Revoking anything that is not Pending, already accepted, declined, or revoked, answers 409
invitation_not_pending. Removing somebody who already joined is DELETE /v1/members/{memberId}, a
different act.
POST /v1/invitations/{invitationId}/resend
Sends the email again. Admin. Answers 200 with the invitation.
A resend renews the clock. expiresAt moves to 14 days from now, which is what makes it the fix
for an expired invitation rather than a second copy of a dead one.
Refused with 409 invitation_not_pending when it is not pending.
Invitations you hold
/v1/me/invitations is the other side: what other organizations have sent to the signed-in person.
These three routes do not take X-Organization-Id. Whoever is being invited does not belong to
the inviting organization yet, so there is no organization to scope to.
GET /v1/me/invitations
Every pending invitation addressed to the caller, across every organization. A plain array.
| Field | Type | Means |
|---|---|---|
id |
uuid | The invitation |
organizationId |
uuid | Who is inviting them |
organizationName |
string | Its name, to show in the prompt |
organizationKind |
OrganizationKind |
0 accounting firm, 1 business |
role |
OrganizationRole |
What they would be |
invitedByName |
string or null | Who sent it |
expiresAt |
date-time | When it stops working |
grantedProfileCount |
integer | How many clients it carries |
hasOrganizationWideAccess |
boolean | Whether the role reaches every client anyway |
Nothing here names the email address, because the invitation was matched to the caller's account already.
POST /v1/me/invitations/{invitationId}/accept
Joins the organization. No body. Answers 200 with the new membership:
{
"organizationId": "11111111-1111-4111-8111-111111111111",
"organizationName": "Example Contabil SRL",
"role": 0
}
Put that organizationId in X-Organization-Id on the next request and the rest of the API opens.
Refusals:
| Code | Status | Means |
|---|---|---|
invitation_not_found |
404 | No such invitation for this person |
invitation_expired |
409 | More than 14 days old. Ask the firm to resend it |
invitation_not_pending |
409 | Already accepted, declined, or revoked |
Accepting when the caller is already a member of that organization is not an error. The invitation is closed and the existing membership is returned unchanged, role and grants included. Clicking an old link twice does not demote anybody.
POST /v1/me/invitations/{invitationId}/decline
Says no. No body. Answers 204. The row moves to status 2 and the link stops working.
There is no undo. A declined invitation needs a new one, not a resend, because a resend only works on a pending row.