---
title: "Roles"
description: "`roles->list`, `listAll`, `iterate`, `get`, `create`, `update`, `delete` and `listPermissions`."
url: "https://openemail.uk/docs/php/roles"
area: "PHP"
category: "Mailbox"
---

# Roles

`roles->list`, `listAll`, `iterate`, `get`, `create`, `update`, `delete` and `listPermissions`.

## Every method

**roles.php**

```
use OpenEmail\Constants\ApiScopes;

$page = $client->roles->list();
echo count($page), PHP_EOL;

$role = $client->roles->get('role_8b1f4c2e9a7d3b60e5f1a2c4');
echo $role['name'], PHP_EOL;

$support = $client->roles->create([
    'name' => 'Support',
    'description' => 'Answers the shared inboxes and nothing else.',
    'permissions' => [ApiScopes::EMAILS_SEND, ApiScopes::THREADS_WRITE, ApiScopes::LABELS_WRITE],
]);

echo implode(', ', $support['permissions']), PHP_EOL;

$client->roles->update($support['id'], ['permissions' => [...$support['permissions'], ApiScopes::TEMPLATES_READ]]);

$client->roles->delete($support['id'], reassignTo: $role['id']);

$vocabulary = $client->roles->listPermissions();
echo implode(', ', array_column($vocabulary, 'id')), PHP_EOL;
```

`$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.

`list` returns one `OpenEmail\Result\Page`, `listAll` returns every role in one array, and `iterate` returns a `Generator` that yields one role at a time. A role comes back as an array keyed in camelCase, so `$role['permissions']` reads the list. `create` and `update` take the body as one array under the API’s names, while `delete` takes `reassignTo:` as a named argument.

A role says what somebody may DO. Which ADDRESSES they may do it to is the other axis and lives on `$client->members`: see `members->grantAddress` and `members->revokeAddress` on the Members page. “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. One permission answers both: a role holding `addresses:all` reaches every address, including ones added later, with no grant, and only a person in the app can put it on a role.

Branch on `editable` and `deletable` rather than on `builtin` or on the 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, as `[...$support['permissions'], ApiScopes::TEMPLATES_READ]` does above. Sending one permission leaves the role holding exactly that one, plus whatever it implies.

> `delete` needs `reassignTo:` the moment anybody holds the role. The client sends it as the `reassignTo` query parameter, because a body on DELETE is dropped by several runtimes and a number of proxies, and leaves the parameter out when you pass nothing. 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 calls that path directly rather than passing the word through `get`, and returns a plain list, not an `OpenEmail\Result\Page`: one array per permission, with `id`, `label`, `group` and `scope`. `scope` false marks the entries no key can ever hold. Do not pass the word to `get` yourself. `$client->roles->get('permissions')` builds the same path, so it sends the same request and gets the vocabulary back in its list envelope rather than a role or a 404.

## 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. A key with no role has no ceiling at all, which makes a null `roleId` 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`. That 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. `$client->me->get()` and `$client->me->ping()` return both in their array, so `array_diff($key['grantedScopes'], $key['scopes'])` lists what the role took. The refusal itself is a `PermissionException` whose `isScopeMissing()` is true.

## 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), thrown as a `ConflictException`, 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. On `create`, leave the key out rather than passing null: the client sends a null as it is, and `create` refuses it with a 422. On `update`, `'description' => null` clears it.
- `permissions` (array, required): What the role grants, drawn from the vocabulary `listPermissions` serves. A string that is not in it is a 422 on `permissions`, thrown as a `ValidationException` with `param` set to `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` (string): Always `role`. The delete tombstone answers with the same value, the role’s `id`, `deleted` set to true and the two reassignment counts, and none of the other fields below.
- `id` (string): The role’s id, read as `$role['id']`. It is what a member’s `roleId` names, what an API key’s ceiling points at, and what `reassignTo:` takes when another role is deleted and its holders move to this one.
- `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 a role called "Admin" says nothing certain about what it holds. A name another role already answers to is `role_name_taken` (409, with `param` set to `name`). Renaming the owner is `role_immutable` (409), like every other edit of it.
- `description` (string or 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` (array): 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 hold equal arrays, which is what lets a settings screen compare them with `===` to decide whether Save is enabled.
- `builtin` (string or null): Which of the six seeded roles this row came from, `owner`, `admin`, `member`, `viewer`, `developer` or `billing`, 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` (bool): Computed as `builtin !== 'owner'`, so it is false for the owner role alone, and every `update` 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` (bool): 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. A role somebody still holds also needs `reassignTo:`, or the delete is `role_in_use` (409). Both refusals are thrown as a `ConflictException`, and `errorCode` tells them apart.
- `members` (int): 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` (int): 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, as an ISO 8601 string. 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, as an ISO 8601 string. Every accepted `update` moves it, including one that sets a field to the value it already held.
