---
title: "Roles"
description: "`roles.list`, `get`, `create`, `update`, `delete` and `listPermissions`."
url: "https://openemail.uk/docs/sdk/roles"
area: "SDK"
category: "Mailbox"
---

# Roles

`roles.list`, `get`, `create`, `update`, `delete` and `listPermissions`.

## Every method

**roles.ts**

```
const roles = await openemail.roles.list()
const role = await openemail.roles.get('role_…')

const support = await openemail.roles.create({
  name: 'Support',
  description: 'Answers the shared inboxes and nothing else.',
  permissions: ['emails:send', 'threads:write', 'labels:write'],
})

console.log(support.permissions)

await openemail.roles.update(support.id, {
  permissions: [...support.permissions, 'templates:read'],
})

await openemail.roles.delete(support.id, { reassignTo: 'role_…' })

const vocabulary = await openemail.roles.listPermissions()
```

`support.permissions` holds six entries, not three: `emails:send` brings `emails:read`, `threads:write` brings `threads:read` and `labels:write` brings `labels:read`. Read the list back rather than assuming it.

A role says what somebody may DO. Which ADDRESSES they may do it to is the other axis and lives on `openemail.members`. See `grantAddress` and `revokeAddress` there. “May send mail” and “may send as invoices@” are different sentences, and a workspace that hires a second support agent changes the second without touching the first.

Branch on `editable` and `deletable` rather than on `builtin`’s name. Both are false for the owner alone, whose list is “every permission, including ones invented next year” and is computed rather than stored; every other role answers true to both, the five a workspace is seeded with included. A role somebody renamed still answers both correctly, and its name no longer tells you anything.

> `update` REPLACES the permission list. There is no grant-one call, so read the role, change the entry you meant and send all of them back. Sending one permission leaves the role holding exactly that one, plus whatever it implies.

> `delete` needs `reassignTo` the moment anybody holds the role, and it travels as a query parameter because a body on DELETE is dropped by several runtimes and a number of proxies. The result reports `reassigned` and `keysReassigned` separately, so a script can log what it did rather than what it asked for.

> `listPermissions()` is `GET /roles/permissions`, a fixed path sitting exactly where a role id would go. The client hard-codes it rather than passing the string through `get`, so asking for a role genuinely called “permissions” asks for a role and gets a 404, which is the honest answer to what was typed. `scope: false` marks the entries no key can ever hold.

## A role is the ceiling on a key

A key issued against a role may do its own scopes INTERSECTED with that role’s permissions, resolved per request at the boundary. So narrowing a role revokes its keys live, without any of them being rotated, and a key with no role has no ceiling at all, which makes a null role the widest state a key can be in, not the narrowest.

That is also why `roles.delete` insists on somewhere to move the keys to. Orphaning them would drop their ceiling entirely, quietly promoting every credential the role was capping.

> `GET /keys/self` and `GET /ping` report `roleId` and `grantedScopes` beside the effective `scopes`, which is how “my key has `emails:send` and I am getting `insufficient_scope`” gets answered: anything in `grantedScopes` and missing from `scopes` was taken by the role. `openemail.me.get()` and `openemail.me.ping()` return both, typed.

## Parameters

- `name` (string, required): What the workspace calls the role: 1 to 48 characters, trimmed before it is stored. Names are unique per workspace case-insensitively, so a second "Support" is refused with `role_name_taken` (409) rather than created alongside the first.
- `description` (string): A sentence saying what the role is for, trimmed and at most 240 characters. A string that is blank once trimmed is stored as null, so a description of spaces comes back as null rather than as what you sent.
- `permissions` (Permission[], required): What the role grants, drawn from the vocabulary `listPermissions()` serves; a string that is not in it is a 422 on `permissions` rather than being quietly dropped, so a typo is reported instead of costing you an afternoon. The list is EXPANDED on the way in (`templates:write` stores `templates:read` beside it), deduplicated and put back into canonical order, so read the stored list off the response rather than assuming it is the one you sent.

## Response

- `object` ('role'): Always `role`. The delete tombstone answers with the same value, the role's `id`, `deleted: true` and the two reassignment counts, and none of the other fields below.
- `id` (string): The role's id. It is what a member's `roleId` names, what an API key's ceiling points at, and what `reassignTo` takes when this role is deleted.
- `name` (string): The workspace's name for the role, trimmed and unique case-insensitively. Every role but the owner's can be renamed, the seeded ones included (`builtin` says where a row came from, not what it has to stay called), so do not read "Admin" as a promise about what the role holds. A name another role already answers to is `role_name_taken` (409, `param: "name"`); renaming the owner is `role_immutable` (409), like every other edit of it.
- `description` (string | null): The sentence describing the role, or null when none was given. Blank input is stored as null on both create and update, so this is never an empty string.
- `permissions` (Permission[]): Everything the role grants, already expanded and in canonical order rather than in the order anybody typed. That ordering is load-bearing: two roles holding the same permissions compare equal as JSON, which is what lets a settings screen diff them to decide whether Save is enabled.
- `builtin` ('owner' | 'admin' | 'member' | 'viewer' | 'developer' | 'billing' | null): Which of the six seeded roles this row came from, or null for one the workspace wrote itself. It records the seed and not a status: a seeded role is renamed, repermissioned and deleted like any other. Branch on `editable` and `deletable` rather than on this. A role somebody called "Admin" need not be the seeded one, and the seeded one may no longer be called that.
- `editable` (boolean): Computed as `builtin !== 'owner'`, so it is false for the owner role alone and every PATCH of that role is refused with `role_immutable` (409). Every other role is editable in full (name, description and permissions), including the five a workspace is seeded with.
- `deletable` (boolean): Computed as `builtin !== 'owner'`: false for the owner role alone, which comes back `role_undeletable` (409), and true for every other role including the seeded ones. Check it before offering the button rather than after the refusal, though a role somebody still holds also needs `reassignTo`, or the delete is `role_in_use` (409).
- `members` (number): How many people hold this role, counted from the workspace's member rows. The owner is not among them: they have no member row and cannot be given a role, so the Owner role reports zero holders even though the members list shows them.
- `apiKeys` (number): How many live API keys are capped by this role; revoked keys are left out of the count, though a delete re-points every key row pointing at the role, revoked ones included. It is the second population that has to be moved before the role can go, and the one nobody notices: keys are programs, and a program does not complain.
- `createdAt` (string): When the role row was written, ISO-8601. Built-in rows are seeded lazily the first time something needs them, such as a roles list read, a role create or the API-key screen, rather than at workspace creation, so a built-in's timestamp is when that first request landed and not when the workspace was made.
- `updatedAt` (string): When the role last changed, ISO-8601. Every accepted PATCH moves it, including one that sets a field to the value it already held.
