# Members

Manage organization and project members, roles and invitations through the Comvi REST API.

Canonical: https://comvi.io/docs/api/members

Members give people access to organizations and projects. Invite by email, assign roles, restrict languages, and remove access.

Every endpoint takes a session cookie, except `GET /v1/invitations/{token}`, which is public. A Bearer key returns `401 AUTHENTICATION_REQUIRED`; see [Sessions](/docs/api/authentication#sessions). Organization endpoints need `organization.manage_members` (owners and managers); project endpoints need `project.manage_members` on the project. Common errors: `401 AUTHENTICATION_REQUIRED`, `403 FORBIDDEN`, `403 READ_ONLY_MODE` on writes. The full list is under [Errors](#errors).

## Roles

| Role | Grants |
|------|--------|
| `owner` | Full control, including billing and member management |
| `manager` | Manage projects, members, and settings |
| `editor` | Edit translations and keys |
| `translator` | Translate content in assigned languages |
| `viewer` | Read-only access |

Owner and manager carry every project permission. At the organization level a manager lacks `organization.delete` and `organization.manage_billing`, so deleting the organization and every billing route are owner-only. Ownership moves only through `POST /v1/organizations/{orgId}/transfer-ownership`.

Organization roles set a baseline; a project role can restrict but not elevate, and a project role above the organization role is rejected with `400 ROLE_HIERARCHY_VIOLATION`. You cannot assign a role above your own, modify a member above you, or change your own role: `403 ROLE_HIERARCHY_VIOLATION` or `403 FORBIDDEN`.

### `GET /v1/organizations/{orgId}/members`

List organization members with their roles. An organization with no members returns an empty array.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `orgId` | path | `integer` | Yes | Organization ID |

```bash
curl -b cookies.txt https://api.comvi.io/v1/organizations/1/members
```

```json
[{ "userId": 43, "email": "bob@example.com", "name": "Bob Johnson", "role": "translator", "joinedAt": "2025-02-15T14:30:00Z", "accessScope": "project", "projectCount": 2 }]
```

| Field | Type | Meaning |
|-------|------|---------|
| `role` | `string` | Organization role |
| `accessScope` | `string` | `organization` for all projects, `project` for a chosen set |
| `projectCount` | `integer` | Projects the member can access. Optional |

### `POST /v1/organizations/{orgId}/members/invite`

Invite by email. An email with a Comvi account is added immediately and no invitation is created; otherwise an invitation is stored and emailed. Invitations expire after 7 days and cannot be resent once expired, see [Recovering an expired invitation](#recovering-an-expired-invitation).

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `orgId` | path | `integer` | Yes | Organization ID |
| `email` | body | `string` | Yes | Address to invite |
| `role` | body | `string` | Yes | One of the five roles |
| `projectId` | body | `integer \| null` | No | Grant one project instead of the whole organization |
| `languageRestrictions` | body | `string[] \| null` | No | Locale codes, such as `["fr", "de"]` |

```bash
curl -X POST -b cookies.txt -H "Content-Type: application/json" \
-d '{"email": "translator@example.com", "role": "translator", "projectId": 5, "languageRestrictions": ["fr", "de"]}' \
https://api.comvi.io/v1/organizations/1/members/invite
```

Returns `200 OK` in both cases with the same shape. The invitation token is never returned: it reaches the invited address only in the invitation email. To tell the two cases apart, list the pending invitations; a new invitation appears there.

```json
{ "id": 12, "email": "translator@example.com", "role": "translator", "projectId": 5, "languageRestrictions": ["fr", "de"], "expiresAt": "2025-04-01T08:00:00Z", "createdAt": "2025-03-25T08:00:00Z" }
```

| Field | New user | Existing user |
|-------|----------|---------------|
| `id` | Invitation ID | User ID |
| `expiresAt` | When the invitation expires | The current time. Ignore it |

### `GET /v1/organizations/{orgId}/members/{userId}/access`

A member's full access picture, for populating an edit form before `PUT …/access`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `orgId` | path | `integer` | Yes | Organization ID |
| `userId` | path | `integer` | Yes | Member's user ID |

```bash
curl -b cookies.txt https://api.comvi.io/v1/organizations/1/members/43/access
```

```json
{ "userId": 43, "accessScope": "project", "role": "translator", "projectIds": [5, 8], "languageRestrictions": ["fr", "de"], "projectAccessEntries": [], "hasProjectOverrides": false, "hasMixedProjectRoles": false, "hasMixedLanguageRestrictions": false, "requiresProjectLevelManagement": false }
```

| Field | Type | Meaning |
|-------|------|---------|
| `projectIds` | `integer[]` | Assigned projects |
| `languageRestrictions` | `string[] \| null` | Locale codes, or `null` for all |
| `projectAccessEntries` | `object[]` | Per-project role and restrictions |
| `hasProjectOverrides` | `boolean` | Some project gives a role other than the organization role |
| `hasMixedProjectRoles` | `boolean` | Per-project roles differ |
| `hasMixedLanguageRestrictions` | `boolean` | Per-project restrictions differ |
| `requiresProjectLevelManagement` | `boolean` | Too varied to edit here; `PUT …/access` refuses with `409`. Edit each project instead |

### `PUT /v1/organizations/{orgId}/members/{userId}/access`

Set scope, role, project assignments and language restrictions in one call.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `orgId` | path | `integer` | Yes | Organization ID |
| `userId` | path | `integer` | Yes | Member's user ID |
| `accessScope` | body | `string` | Yes | `organization` or `project` |
| `role` | body | `string` | Yes | Role to apply |
| `projectIds` | body | `integer[]` | When `project` | Duplicates fail schema validation with `422`; an empty array returns `400` |
| `languageRestrictions` | body | `string[] \| null` | No | Locale codes, or `null` for all |

```bash
curl -X PUT -b cookies.txt -H "Content-Type: application/json" \
-d '{"accessScope": "project", "role": "translator", "projectIds": [5, 8], "languageRestrictions": ["fr", "de"]}' \
https://api.comvi.io/v1/organizations/1/members/43/access
```

Returns `204 No Content`.

### `PATCH /v1/organizations/{orgId}/members/{userId}`

Change a member's organization role, leaving project assignments alone. Demoting the last owner returns `400 CANNOT_REMOVE_LAST_OWNER`; demoting an owner while a paid subscription is linked returns `400 ACTIVE_SUBSCRIPTION_REQUIRED`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `orgId` | path | `integer` | Yes | Organization ID |
| `userId` | path | `integer` | Yes | Member's user ID |
| `role` | body | `string` | Yes | New role |

```bash
curl -X PATCH -b cookies.txt -H "Content-Type: application/json" -d '{"role": "editor"}' \
https://api.comvi.io/v1/organizations/1/members/43
```

Returns `204 No Content`.

### `POST /v1/organizations/{orgId}/transfer-ownership`

Hand ownership to another member; the current owner becomes a manager. Only the current owner may call it, although the route checks `organization.manage_members`, and the new owner must already have organization-wide access.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `orgId` | path | `integer` | Yes | Organization ID |
| `newOwnerId` | body | `integer` | Yes | User ID of the new owner |

```bash
curl -X POST -b cookies.txt -H "Content-Type: application/json" -d '{"newOwnerId": 43}' \
https://api.comvi.io/v1/organizations/1/transfer-ownership
```

Returns `204 No Content`.

| Status and code | When |
|-----------------|------|
| `400 INVALID_INPUT` | `newOwnerId` is the caller |
| `403 FORBIDDEN` | The caller is not the current owner |
| `409 CONFLICT` | The target has project-scoped access, or is already an owner |

### `DELETE /v1/organizations/{orgId}/members/{userId}`

Remove a user from the organization and every project in it. You cannot remove yourself or the last owner.

```bash
curl -X DELETE -b cookies.txt https://api.comvi.io/v1/organizations/1/members/43
```

Returns `204 No Content`.

### `GET /v1/organizations/{orgId}/invitations`

List invitations not yet accepted. Expired ones are included, so check `expiresAt`.

```bash
curl -b cookies.txt https://api.comvi.io/v1/organizations/1/invitations
```

```json
[{ "id": 12, "email": "translator@example.com", "role": "translator", "invitedByUserId": 42, "invitedByName": "Jane Smith", "projectId": 5, "projectName": "my-web-app", "languageRestrictions": ["fr", "de"], "expiresAt": "2025-04-01T08:00:00Z", "createdAt": "2025-03-25T08:00:00Z" }]
```

### `GET /v1/invitations/{token}`

What an invitation is for, so the recipient can see who invited them and to what before signing up. Public: the 64-character token is the only credential. An expired token returns `400 INVITATION_EXPIRED` and deletes the invitation, so anyone opening an expired link removes it; afterwards it is absent from the list and cancel and resend return `404 INVITATION_NOT_FOUND`. To recover, invite again: the duplicate check finds no row, so the re-invite succeeds with a fresh token and a new 7-day window.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `token` | path | `string` | Yes | Token from the email link, 64 characters |

```bash
curl https://api.comvi.io/v1/invitations/a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
```

```json
{ "email": "translator@example.com", "organizationName": "Acme Corp", "role": "translator", "inviterName": "Jane Smith", "projectName": "my-web-app", "languageRestrictions": ["fr", "de"], "expiresAt": "2025-04-01T08:00:00Z" }
```

| Status and code | When |
|-----------------|------|
| `400 INVITATION_EXPIRED` | Expired. The invitation is deleted |
| `404 INVITATION_NOT_FOUND` | Unknown or already used token |

### `POST /v1/invitations/{token}/accept`

Join the organization the invitation names. The caller must be signed in as the invited email address. No body.

```bash
curl -X POST -b cookies.txt https://api.comvi.io/v1/invitations/a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90/accept
```

```json
{ "organizationId": 1, "organizationName": "Acme Corp", "role": "translator" }
```

| Status and code | When |
|-----------------|------|
| `400 INVITATION_EXPIRED` | Expired. The invitation is deleted, so the token cannot be tried again |
| `400 USER_ALREADY_IN_ORGANIZATION` | Already a member. The invitation is deleted |
| `403 INVALID_INVITATION` | Signed in with a different email than the invitation was sent to |

### `POST /v1/organizations/{orgId}/invitations/{invitationId}/resend`

Send the invitation email again with the same token; the expiry is not extended. An expired invitation returns `400 INVITATION_EXPIRED` and nothing is sent.

```bash
curl -X POST -b cookies.txt https://api.comvi.io/v1/organizations/1/invitations/12/resend
```

Returns `204 No Content`.

### `DELETE /v1/organizations/{orgId}/invitations/{invitationId}`

Delete a pending invitation; its token stops working. An expired invitation can be cancelled as long as its row still exists.

```bash
curl -X DELETE -b cookies.txt https://api.comvi.io/v1/organizations/1/invitations/12
```

Returns `204 No Content`, or `404 INVITATION_NOT_FOUND` when the row is already gone.

### Recovering an expired invitation

Start with `GET /v1/organizations/{orgId}/invitations`. If the invitation is listed, cancel it, then invite again; skipping the cancel returns `400 INVITATION_ALREADY_SENT`, because the duplicate check matches on email, organization and project without looking at `expiresAt`. If it is not listed, someone opened its link and it was deleted; invite again directly. Either way the new invitation carries a fresh token and a new 7-day window, and the old token stops working.

### `GET /v1/projects/{projectId}/members`

Everyone explicitly assigned to the project, with organization and project roles.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |

```bash
curl -b cookies.txt https://api.comvi.io/v1/projects/5/members
```

```json
[{ "userId": 44, "email": "maria@example.com", "name": "Maria Garcia", "organizationRole": "translator", "projectRole": "translator", "languageRestrictions": ["es", "pt"], "addedAt": "2025-03-01T12:00:00Z" }]
```

`languageRestrictions` is `null` for all languages.

### `POST /v1/projects/{projectId}/members`

Add an existing organization member to a project. A `role` above the user's organization role returns `400 ROLE_HIERARCHY_VIOLATION`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `userId` | body | `integer` | Yes | Organization member to add |
| `role` | body | `string` | Yes | Project role |
| `languageRestrictions` | body | `string[]` | No | Locale codes |

```bash
curl -X POST -b cookies.txt -H "Content-Type: application/json" \
-d '{"userId": 44, "role": "translator", "languageRestrictions": ["es", "pt"]}' \
https://api.comvi.io/v1/projects/5/members
```

Returns `201 Created` with an empty body.

### `PATCH /v1/projects/{projectId}/members/{userId}`

Update a project member's role or language restrictions.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `userId` | path | `integer` | Yes | Member's user ID |
| `role` | body | `string` | Yes | New project role |
| `languageRestrictions` | body | `string[] \| null` | No | Locale codes, or `null` for all |

```bash
curl -X PATCH -b cookies.txt -H "Content-Type: application/json" \
-d '{"role": "editor", "languageRestrictions": null}' https://api.comvi.io/v1/projects/5/members/44
```

Returns `204 No Content`.

### `DELETE /v1/projects/{projectId}/members/{userId}`

Remove a user from a project, not from the organization. You cannot remove yourself, and the user must be explicitly assigned: removing someone with organization-wide access only returns `404 NO_PROJECT_ACCESS`.

```bash
curl -X DELETE -b cookies.txt https://api.comvi.io/v1/projects/5/members/44
```

Returns `204 No Content`.

## Errors

The only `409` is `CONFLICT`; the "already" conditions are `400`.

| Status and code | When |
|-----------------|------|
| `400 CANNOT_REMOVE_LAST_OWNER` | Removing or demoting the last owner |
| `400 ROLE_HIERARCHY_VIOLATION` | A project role would exceed the organization role |
| `400 USER_ALREADY_IN_ORGANIZATION` | Already a member, or already has access to the project |
| `400 INVITATION_ALREADY_SENT` | An invitation is pending for this email |
| `400 INVITATION_EXPIRED` | The invitation has expired |
| `400 ACTIVE_SUBSCRIPTION_REQUIRED` | Removing an owner while a paid subscription is linked |
| `400 INVALID_INPUT` | Empty `projectIds` on a project-scoped update, or transferring ownership to yourself |
| `401 AUTHENTICATION_REQUIRED` | Missing session cookie, or a Bearer key |
| `403 FORBIDDEN` | Missing `organization.manage_members` or `project.manage_members`, or modifying your own access |
| `403 ROLE_HIERARCHY_VIOLATION` | Assigning or modifying a role above your own |
| `403 INVALID_INVITATION` | The invitation was sent to a different email |
| `403 LIMIT_EXCEEDED` | Member limit reached for the plan |
| `403 READ_ONLY_MODE` | The organization is read-only |
| `404 USER_NOT_FOUND` | The user does not exist. Project member endpoints return the same for a user outside the project's organization |
| `404 NOT_ORGANIZATION_MEMBER` | The target user is not in the organization. `403` when the caller is not |
| `404 NO_PROJECT_ACCESS` | The target user is not explicitly assigned to the project |
| `404 ORGANIZATION_NOT_FOUND` | The organization does not exist |
| `404 PROJECT_NOT_FOUND` | The project does not exist |
| `404 INVITATION_NOT_FOUND` | The invitation does not exist or was used |
| `409 CONFLICT` | Ownership transfer to an ineligible member, or an access update blocked by project-level overrides |
| `422 VALIDATION_ERROR` | Duplicate entries in `projectIds`. `meta.field` names the field |
| `422 FIELD_REQUIRED` | A required body field is missing, such as `email`, `role`, `accessScope` or `newOwnerId` |
| `500 CACHE_INVALIDATION_FAILED` | The permission cache could not be flushed first. Retry |

## Next steps

- [Roles and permissions](/docs/platform/roles-permissions) for the full permission model
- [Organizations](/docs/api/organizations) to create organizations
- [Projects](/docs/api/projects) for the projects members are assigned to
