---
title: "Roles"
description: "Every operation in this group: what it accepts, what it returns and the errors it can answer with."
url: "https://openemail.uk/docs/api/reference/roles"
area: "API"
category: "Reference"
---

# Roles

Every operation in this group: what it accepts, what it returns and the errors it can answer with.

## Operations

What somebody may DO in this workspace. A role is a list of permissions drawn from the same alphabet as the API scopes, and that shared alphabet is what makes the interesting question answerable at all: a key issued by a member who may not touch templates is a key that may not touch templates.

The rule to hold on to is the intersection. The authority of a key is its own scopes INTERSECTED with the permissions of the role it was issued under, never the union, and it is resolved on every request rather than frozen into the token, so narrowing a role takes effect on the next request made under it, with no key rotation needed to make it stick, and widening one takes effect just as fast. A key with no role has no ceiling, which is what every key issued before roles existed still has.

Six roles are seeded on every workspace: owner, admin, member, viewer, developer and billing. They arrive on the first read of `GET /roles` rather than at workspace creation, so a workspace older than the feature acquires them the moment anybody looks. Only the OWNER is fixed. It holds every permission, including the ones added next year, and refuses every edit, rename and deletion. The other five are a starting point and nothing more: rename them, rewrite what they grant, or delete the ones this workspace has no use for. `builtin` records which template a role was seeded from, which is what fixes the list order and what a deletion falls back to; it is not a claim about what may be done to it.

The other axis lives next door under Members: which ADDRESSES somebody may act on. Both have to agree before anything happens.

### `GET /roles`

List roles

Every role this workspace defines: the seeded ones first in ladder order (owner, admin, member, viewer, then developer and billing), and everything else alphabetically after them. Not newest-first like the rest of the API, because a permission matrix is read as a ladder and ordering it by `createdAt` puts the widest role in a different row every week. A renamed seed keeps its rung: the order is read off `builtin`, not off the name.

A page at a time: follow `nextCursor` while `hasMore` is true to read every role. Custom roles are capped on purpose: a workspace with forty of them cannot answer "who can send as billing@" by looking, which is the only question the feature exists to make answerable.

A workspace older than the feature has no role rows at all, and this read SEEDS them rather than reporting an empty list. The seeder writes the six templates and rewrites nothing else, so an edited or renamed role is left exactly as it was, and it is what makes `POST /members` able to name a `roleId` that exists. The one row it does rewrite is `owner`, whose permission list is reset from the current vocabulary on every read, so a workspace seeded two releases ago still holds the permissions added since.

It seeds ONCE. The workspace records that it has been seeded, so this read fills in a workspace older than the feature and then never writes the templates again. That is what a client should know before it draws a confirmation dialog: deleting a seeded role is permanent. A deleted Billing stays deleted and does not come back under a new id on the next read. `owner` is the exception, restored on every read because it is the row the workspace is keyed on, and it cannot be deleted through this API in any case.

What a KEY may do is its own scopes INTERSECTED with the permissions of the role it was issued under, never the union. A role here that lacks `emails:send` is a ceiling: a key carrying that scope under that role cannot send, whatever the token says it holds. A key with no role has no ceiling, which is what every key issued before roles existed still has.

Requires the `roles:read` scope.

- Scopes: `roles:read`.

**Query parameters**

- `limit` (`integer`, at least 1, at most 100, default `25`): Rows per page, 1 to 100.
- `cursor` (`string`): The previous page's `nextCursor`, passed back as it came. It is opaque: it holds where the last row sat in this list's order, so a row deleted or edited between pages never breaks the walk, and the next page starts at the first row that sorts after it. A value this list did not hand out is a 400 `invalid_cursor`.

**Returns**

- `200` `RoleList`: A page of roles, the seeded ones first.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`roles.list()`](https://openemail.uk/docs/sdk/reference/roles#list), [`roles.listAll()`](https://openemail.uk/docs/sdk/reference/roles#listAll), [`roles.iterate()`](https://openemail.uk/docs/sdk/reference/roles#iterate); CLI [`openemail roles list`](https://openemail.uk/docs/cli/reference/roles#roles-list); MCP [`listDomains`](https://openemail.uk/docs/mcp/tools/domains#listDomains), [`listRoles`](https://openemail.uk/docs/mcp/tools/workspace#listRoles).

### `POST /roles`

Create a role

Writes a role this workspace invents for itself. `builtin` comes back null, and only rows like that count against the ceiling. The seeded roles already exist and are not created here; they are not fixtures either, so shaping one of them into what this workspace calls people is often a `PATCH` rather than a new role.

Implied permissions are expanded as it is written, so a body naming `templates:write` alone comes back holding `templates:read` as well. Storing the expanded list is what lets every reader (this API, the console, the key middleware) check one flat array rather than each of them knowing the implication table.

Mind what `roles:write` is. A key holding it can `PATCH` the very role that caps it and hand itself the rest of the vocabulary on the next request, because the ceiling is resolved per request rather than frozen into the token. That is the same authority the console gives anybody who can edit roles, and it is not a hole to be plugged here. A role editor that cannot edit its own role is not a role editor. It is a reason not to put `roles:write` on a key that only ever needed to read the members list.

A permission the calling key or token does not hold is refused with `insufficient_authority`, a 403, and none of them holds a console-only permission (`scope: false` on `GET /roles/permissions`). A role holding `addresses:all`, billing, `workspace:manage` or the API-key pair is written in the app.

Requires the `roles:write` scope.

- Scopes: `roles:write`.

**Request body**

- `name` (`string`, required, 1 to 48 characters): Unique per workspace. Trimmed on the way in; a name of nothing but spaces is a 422.
- `description` (`string`, up to 240 characters): For whoever reads the role list later. One sentence about who it is for.
- `permissions` (`string[]`, required, up to 44 items, one of `"emails:send"`, `"emails:read"`, `"drafts:read"`, `"drafts:write"`, `"threads:read"`, `"threads:write"`, `"files:read"`, `"files:write"`, `"labels:read"`, `"labels:write"`, `"contacts:read"`, `"contacts:write"`, `"audiences:read"`, `"audiences:write"`, `"calendar:read"`, `"calendar:write"`, `"templates:read"`, `"templates:write"`, `"domains:read"`, `"domains:write"`, `"webhooks:read"`, `"webhooks:write"`, `"rules:read"`, `"rules:write"`, `"connections:read"`, `"members:read"`, `"members:write"`, `"roles:read"`, `"roles:write"`, `"settings:read"`, `"settings:write"`, `"keys:write"`, `"keys:read"`, `"keys:manage"`, `"forms:read"`, `"forms:write"`, `"billing:read"`, `"billing:write"`, `"account:read"`, `"account:write"`, `"addresses:all"`, `"api-keys:read"`, `"api-keys:write"`, `"workspace:manage"`): The whole list, and only entries from the vocabulary. A string this API does not recognise is REFUSED rather than dropped: the normaliser behind this ignores what it cannot place, which is right where it also serves the seeding path, and wrong here. A caller who posts `templates:writ`, gets a 201 back and discovers the role cannot edit templates has been told nothing and will spend the afternoon on it. Implied permissions are expanded on the way in, so what comes back may be longer than what was sent. `templates:write` alone stores as `templates:read` and `templates:write`, because a role that can edit a template it cannot open is not a policy anybody means.

**Returns**

- `201` `Role`: Created. Held by nobody yet, so both counts are zero.

**Errors**

- `409`: `role_name_taken`. Names are unique per workspace.
- `422`: `invalid_parameter` naming an unrecognised permission, or `workspace_limit_reached` when the workspace is at its limit of custom roles.
- The errors every operation can return: `400`, `401`, `403`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`roles.create()`](https://openemail.uk/docs/sdk/reference/roles#create); CLI [`openemail roles create`](https://openemail.uk/docs/cli/reference/roles#roles-create); MCP [`createRole`](https://openemail.uk/docs/mcp/tools/workspace#createRole).

### `GET /roles/permissions`

Every permission, described

The vocabulary a role is written in: every permission, the sentence to show a person for it, the heading it belongs under, and whether a key may hold it at all.

Served rather than left to be transcribed, for the same reason the rule vocabularies are derived rather than described: a matrix built from a copied array keeps offering a permission the day one is renamed, and never offers the one added last week.

`scope` is the field to branch on. Permissions and scopes are one alphabet on purpose (the authority of a key is its own scopes intersected with the permissions of its role, and an intersection is only computable if both sides are drawn from the same list), but they are not the same set. The six console-only entries (`addresses:all`, the API-key pair, billing, `workspace:manage`) exist so that a role can say who reaches every address, who may see or move the plan and who may manage the workspace, and no token can ever carry them. The API-key pair is listed too, but API keys stay with the workspace owner whatever a role holds. A key-creation checklist filters on `scope`; a role matrix does not.

In canonical order, which is the order a stored `permissions` array comes back in, so a client rendering this list and a client rendering a role show the same permissions in the same sequence.

Behind `roles:read`, where the console has the same catalogue ungated. The divergence is deliberate: a signed-in person has to be shown the matrix before they can be told which parts of it they may not touch, whereas every request here already carries a key, and a key that may not read roles has no use for the vocabulary of roles.

Requires the `roles:read` scope.

- Scopes: `roles:read`.

**Returns**

- `200` `PermissionList`: The whole vocabulary, in canonical order.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`roles.listPermissions()`](https://openemail.uk/docs/sdk/reference/roles#listPermissions); CLI [`openemail roles list-permissions`](https://openemail.uk/docs/cli/reference/roles#roles-list-permissions); MCP [`listDomains`](https://openemail.uk/docs/mcp/tools/domains#listDomains), [`listPermissions`](https://openemail.uk/docs/mcp/tools/workspace#listPermissions), [`listRoles`](https://openemail.uk/docs/mcp/tools/workspace#listRoles).

### `GET /roles/{id}`

Retrieve a role

`members` and `apiKeys` are counted at read time rather than stored, so they are the answer now and not the answer when somebody last edited the role. They are what a deletion would have to move.

Requires the `roles:read` scope.

- Scopes: `roles:read`.

**Path parameters**

- `id` (`string`, required): A `role_` id. Roles have no slug. The name is editable and therefore not a handle.

**Returns**

- `200` `Role`: The role, with its usage counts.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`roles.get()`](https://openemail.uk/docs/sdk/reference/roles#get); CLI [`openemail roles get`](https://openemail.uk/docs/cli/reference/roles#roles-get); MCP [`getRole`](https://openemail.uk/docs/mcp/tools/workspace#getRole).

### `PATCH /roles/{id}`

Update a role

Rename a role, rewrite its sentence, or replace what it grants.

`permissions` is REPLACE-WHOLE. There is no way to add or remove a single one and there will not be: the list is what gets audited, and an index-addressed patch is a lost update the first time two tabs are open. "Add `domains:write`" is a `GET` and a `PATCH` in a client that already has the array on screen.

The owner role refuses every edit (changing it would be changing what "owner" means, which is not for a workspace to decide), and it is the only role that refuses one. Everything else takes all three fields, the seeded Admin, Member, Viewer, Developer and Billing included: renaming Billing to "Finance" and rewriting what it grants is an ordinary `PATCH`. `builtin` does not move with the name; it goes on recording which template the row was seeded from, which is what keeps the list order stable.

The edit is LIVE. Authority is resolved per request (the scopes on a key intersected with the permissions on this role, every time), so narrowing a role takes effect on the next request anybody holding it makes, with no key rotation needed to make it stick, and widening one takes effect just as immediately, which is the half worth remembering before handing somebody a role to edit.

Adding a permission the calling key or token does not hold is refused with `insufficient_authority`, a 403, and none of them holds a console-only one such as `addresses:all`. One the role already holds is kept only if the PATCH sends it back, and a console-only one cannot be added back through this API, so send back every entry you read.

Requires the `roles:write` scope.

- Scopes: `roles:write`.
- Asks an OAuth access token for a verification code.

**Path parameters**

- `id` (`string`, required): A `role_` id. Roles have no slug. The name is editable and therefore not a handle.

**Request body**

- `name` (`string`, 1 to 48 characters): Unique per workspace. Trimmed on the way in; a name of nothing but spaces is a 422.
- `description` (`string`, nullable, up to 240 characters): Nullable as well as optional, and the difference is the whole point of a patch: omit it to keep the stored sentence, send `null` to clear it. Without the null there would be no way to remove a description once written except by replacing it with a space.
- `permissions` (`string[]`, up to 44 items, one of `"emails:send"`, `"emails:read"`, `"drafts:read"`, `"drafts:write"`, `"threads:read"`, `"threads:write"`, `"files:read"`, `"files:write"`, `"labels:read"`, `"labels:write"`, `"contacts:read"`, `"contacts:write"`, `"audiences:read"`, `"audiences:write"`, `"calendar:read"`, `"calendar:write"`, `"templates:read"`, `"templates:write"`, `"domains:read"`, `"domains:write"`, `"webhooks:read"`, `"webhooks:write"`, `"rules:read"`, `"rules:write"`, `"connections:read"`, `"members:read"`, `"members:write"`, `"roles:read"`, `"roles:write"`, `"settings:read"`, `"settings:write"`, `"keys:write"`, `"keys:read"`, `"keys:manage"`, `"forms:read"`, `"forms:write"`, `"billing:read"`, `"billing:write"`, `"account:read"`, `"account:write"`, `"addresses:all"`, `"api-keys:read"`, `"api-keys:write"`, `"workspace:manage"`): The whole list, and only entries from the vocabulary. A string this API does not recognise is REFUSED rather than dropped: the normaliser behind this ignores what it cannot place, which is right where it also serves the seeding path, and wrong here. A caller who posts `templates:writ`, gets a 201 back and discovers the role cannot edit templates has been told nothing and will spend the afternoon on it. Implied permissions are expanded on the way in, so what comes back may be longer than what was sent. `templates:write` alone stores as `templates:read` and `templates:write`, because a role that can edit a template it cannot open is not a policy anybody means.

**Returns**

- `200` `Role`: Saved. In force on the next request anybody holding it makes.

**Errors**

- `403`: The key lacks the scope, or may not send as that address. `step_up_required`: the call was made with an OAuth access token that has not been verified in the last 60 minutes. Ask for a code with `POST /security/step-up`, send it to `POST /security/step-up/verify`, then repeat the call. The person can also choose Allow changes for 60 minutes on the app in Account settings, Connected apps, on the OpenEmail website. An API key is never asked for a code.
- `409`: `role_name_taken` with `param: "name"` (names are unique per workspace, case-insensitively), or `role_immutable` with `param: "roleId"`, which is the owner role and nothing else.
- The errors every operation can return: `400`, `401`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`roles.update()`](https://openemail.uk/docs/sdk/reference/roles#update); CLI [`openemail roles update`](https://openemail.uk/docs/cli/reference/roles#roles-update); MCP [`updateRole`](https://openemail.uk/docs/mcp/tools/workspace#updateRole).

### `DELETE /roles/{id}`

Delete a role

Deletes a role and moves everybody who held it. Only the owner refuses: the roles a workspace was seeded with go the same way as one somebody wrote, because a workspace that never uses Developer or Billing should be able to be rid of them.

Deleting a seeded role is permanent, the same as deleting one somebody wrote. Seeding is a one-time bootstrap recorded against the workspace, not a reconciliation run on every read, so nothing puts a deleted role back.

API keys are re-pointed rather than orphaned, and that is the part worth reading twice: a key whose role had vanished would fall back to NO ceiling, and no ceiling is WIDER than a narrow role, so deleting a restrictive role would quietly promote every key it had been capping. Refusing without `reassignTo` is what keeps that from being a one-word request.

The two counts come back separately because they are two different things to go and check afterwards. `reassigned` is people; `keysReassigned` is programs, which will carry on running under their new ceiling without anybody being told.

A tombstone rather than a 204, matching the rest of the API: the id comes back so a log line can name what went.

Requires the `roles:write` scope.

- Scopes: `roles:write`.
- Asks an OAuth access token for a verification code.

**Path parameters**

- `id` (`string`, required): A `role_` id. Roles have no slug. The name is editable and therefore not a handle.

**Query parameters**

- `reassignTo` (`string`): A role id to move the holders to. Required the moment the role is actually held; a role held by nobody deletes without it, and the service refuses to guess a destination in either case. A query parameter rather than a body, because a DELETE body is dropped by several `fetch` implementations and by a fair number of proxies. A caller who sent one would be told the role is still in use with no way to see that their body never arrived.

**Returns**

- `200` `object`: Deleted, with what had to be moved.
  - `object` (`string`, one of `"role"`)
  - `id` (`string`)
  - `deleted` (`boolean`, one of `true`)
  - `reassigned` (`integer`): People moved to `reassignTo`. They will notice.
  - `keysReassigned` (`integer`): API keys re-pointed at `reassignTo`. They will not.

**Errors**

- `403`: The key lacks the scope, or may not send as that address. `step_up_required`: the call was made with an OAuth access token that has not been verified in the last 60 minutes. Ask for a code with `POST /security/step-up`, send it to `POST /security/step-up/verify`, then repeat the call. The person can also choose Allow changes for 60 minutes on the app in Account settings, Connected apps, on the OpenEmail website. An API key is never asked for a code.
- `409`: `role_in_use` with `param: "reassignTo"` (name somewhere to move the holders to), or `role_undeletable`, which is the owner role and nothing else. `role_immutable` on `reassignTo` is the third: the owner role cannot be the destination either.
- The errors every operation can return: `400`, `401`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`roles.delete()`](https://openemail.uk/docs/sdk/reference/roles#delete); CLI [`openemail roles delete`](https://openemail.uk/docs/cli/reference/roles#roles-delete); MCP [`deleteRole`](https://openemail.uk/docs/mcp/tools/workspace#deleteRole).

### Objects

#### `PermissionDescriptor`

`object`

- `object` (`string`, one of `"permission"`)
- `id` (`string`, one of `"emails:send"`, `"emails:read"`, `"drafts:read"`, `"drafts:write"`, `"threads:read"`, `"threads:write"`, `"files:read"`, `"files:write"`, `"labels:read"`, `"labels:write"`, `"contacts:read"`, `"contacts:write"`, `"audiences:read"`, `"audiences:write"`, `"calendar:read"`, `"calendar:write"`, `"templates:read"`, `"templates:write"`, `"domains:read"`, `"domains:write"`, `"webhooks:read"`, `"webhooks:write"`, `"rules:read"`, `"rules:write"`, `"connections:read"`, `"members:read"`, `"members:write"`, `"roles:read"`, `"roles:write"`, `"settings:read"`, `"settings:write"`, `"keys:write"`, `"keys:read"`, `"keys:manage"`, `"forms:read"`, `"forms:write"`, `"billing:read"`, `"billing:write"`, `"account:read"`, `"account:write"`, `"addresses:all"`, `"api-keys:read"`, `"api-keys:write"`, `"workspace:manage"`): One permission from the closed vocabulary. Every API scope is a permission; the reverse does not hold. See `scope` on `PermissionDescriptor`.
- `label` (`string`): What to put beside the checkbox. The same sentence the scope list on a key shows, from the same table, so nobody is told two different things about one word.
- `group` (`string`, one of `"addresses"`, `"mail"`, `"organising"`, `"writing"`, `"workspace"`, `"people"`, `"developer"`, `"other"`): The heading it renders under. `other` is the fallback for a permission that has been added to the vocabulary and not yet placed in a group, reported rather than omitted, because a permission nobody can see in the matrix is a permission nobody audits.
- `scope` (`boolean`): Whether an API KEY can hold this. False for the six console-only permissions (`addresses:all`, the API-key pair, billing and `workspace:manage`), which only a role holds and no token can ever carry: they say who reaches every address, who may see or change the plan and who may manage the workspace. The API-key pair is listed too, but API keys stay with the workspace owner whatever a role holds. Filter a key-creation checklist on this rather than on a list of your own.

#### `PermissionList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`PermissionDescriptor[]`)

#### `Role`

`object`

- `object` (`string`, one of `"role"`)
- `id` (`string`): The durable handle, `role_` + 24 hex.
- `name` (`string`): Unique per workspace and compared case-insensitively, so "support" beside "Support" is a 409 rather than a silent duplicate. Editable on every role but the owner, which makes a name a label and never a handle: `builtin` is what says which template a row was seeded from, and `id` is what names it again tomorrow.
- `description` (`string`, nullable)
- `permissions` (`string[]`, one of `"emails:send"`, `"emails:read"`, `"drafts:read"`, `"drafts:write"`, `"threads:read"`, `"threads:write"`, `"files:read"`, `"files:write"`, `"labels:read"`, `"labels:write"`, `"contacts:read"`, `"contacts:write"`, `"audiences:read"`, `"audiences:write"`, `"calendar:read"`, `"calendar:write"`, `"templates:read"`, `"templates:write"`, `"domains:read"`, `"domains:write"`, `"webhooks:read"`, `"webhooks:write"`, `"rules:read"`, `"rules:write"`, `"connections:read"`, `"members:read"`, `"members:write"`, `"roles:read"`, `"roles:write"`, `"settings:read"`, `"settings:write"`, `"keys:write"`, `"keys:read"`, `"keys:manage"`, `"forms:read"`, `"forms:write"`, `"billing:read"`, `"billing:write"`, `"account:read"`, `"account:write"`, `"addresses:all"`, `"api-keys:read"`, `"api-keys:write"`, `"workspace:manage"`): What somebody holding this role may DO. Which addresses they may do it to is the other axis, and it lives on `Member.addresses`, with one exception: `addresses:all` reaches every address on every domain of the workspace, including ones added later, with no grant at all.
- `builtin` (`string`, nullable, one of `"owner"`, `"admin"`, `"member"`, `"viewer"`, `"developer"`, `"billing"`): Which template this row was seeded from, or null for a role somebody wrote. The seeds arrive on the first read of `GET /roles`, so a workspace older than the feature still has them. It is provenance and sort order, NOT protection: every value but `owner` can be renamed, rewritten and deleted like any other role, and a renamed one keeps this field.
- `editable` (`boolean`): False only for `owner`. Everything else takes a new name, a new description and a new permission list, the seeded roles included.
- `deletable` (`boolean`): True for every role but the owner. It also answers "may this be renamed", which is why there is no second flag saying so. It equals `editable` in every case today; the two are still reported apart because they name two different refusals, and a client should read the one it is about rather than learn that they happen to coincide.
- `members` (`integer`): People holding it. The owner is never counted.
- `apiKeys` (`integer`): Keys capped by it. The quiet half of a deletion: people notice losing a role and programs do not.
- `createdAt` (`string`, format `date-time`)
- `updatedAt` (`string`, format `date-time`)

#### `RoleList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Role[]`)
- `hasMore` (`boolean`): True when another page follows. Pass `nextCursor` back as `cursor` to read it.
- `nextCursor` (`string`, nullable): An opaque cursor for the next page, or null on the last page. Pass it back unchanged.
