Skip to the documentation
API

List roles

Every role on the workspace, built-ins first, with how many people and keys hold each.

GETapi.openemail.uk/roles

Runs the real call against your workspace, with your own key.

GET /roles

Every role on the workspace, built-ins first, with how many people and keys hold each.

Two axes, and they are not the same question

shell
export OE=https://api.openemail.ukexport AUTH="Authorization: Bearer $OPENEMAIL_API_KEY"

A ROLE says what somebody may DO in this workspace: read mail, send it, edit templates, add a domain. A GRANT says which ADDRESSES they may do it to, and lives next door on /members/{userId}/addresses as member (reads the address and sends as it) or viewer (reads it only). Both have to agree before a message goes out: a role holding emails:send with no grants can send from nothing, and every address in the workspace under a viewer grant can send from nothing either.

Every workspace is seeded with the same six roles. Owner, Admin, Member and Viewer form a ladder. Each holds everything the next one does, so demoting somebody narrows their access rather than swapping it for a different slice. Developer and Billing are not rungs on it: Developer builds integrations (keys, webhooks, templates, sending) and reads none of the workspace’s mail, and Billing sees the plan and the invoices and nothing else. Both sit strictly inside Admin. They are seeded on the first read rather than at workspace creation, so a workspace made before this feature existed grows them the moment anything asks. builtin names which seed a row came from, and that is the whole of what it names: the six are a starting point a workspace is meant to shape, and every one of them but Owner can be renamed, repermissioned and deleted. Branch on editable and deletable rather than on the name: a role somebody renamed still answers those two correctly, and its name no longer tells you anything.

Owner is the only exception, and it is an exception in every direction: editable: false, deletable: false, and refused as a target on PATCH /members/{userId}. It describes the account the workspace is keyed on and holds every permission, including ones added in a later release, which is why its list is computed rather than stored. Making somebody else the owner is a workspace transfer; there is no endpoint here that performs one.

The other five accept everything: a new permission list, a new description, a new name, a DELETE. They are seeded defaults rather than fixtures: a workspace that never builds an integration should be able to be rid of Developer, and one where “Member” means something narrower should be able to say so in its own words. Only the owner refuses, and it refuses the lot under one code: role_immutable, a 409 carrying param: "roleId", whether the PATCH held a name or a permission list. No rename is refused on its own any more, so there is no param: "name" immutability to handle; the only 409 a name can still raise is role_name_taken, when another role on the workspace already answers to it.

Past the six, a workspace writes up to 24 roles of its own. The ceiling counts only those, so deleting a seeded role buys no room under it. Permissions are EXPANDED on the way in rather than taken literally (templates:write alone is stored as templates:read and templates:write), so read the list back off the response rather than assuming it is the one you sent.

A role is also the ceiling on an API key. A key issued against one may do key.scopes ∩ role.permissions and no more, resolved per request at the boundary, so editing a role changes what its keys may do on their very next call, and a key with no role has no ceiling at all. The Scopes page has the whole of that.

Example

Needs roles:read. Uncursored. The envelope carries hasMore and nextCursor so a client can hand it to the same list code as every other collection, and there is never a second page.

curl
curl "$OE/roles" -H "$AUTH"
Response
{  "object": "list",  "data": [    {      "object": "role",      "id": "role_1c94e05d3862c1f0a44b7f3a",      "name": "Owner",      "description": "The person the workspace belongs to. Holds everything, including additions.",      "permissions": ["emails:send", "emails:read", "…", "workspace:manage"],      "builtin": "owner",      "editable": false,      "deletable": false,      "members": 0,      "apiKeys": 2,      "createdAt": "2026-08-01T09:00:00.000Z",      "updatedAt": "2026-08-01T09:00:00.000Z"    },    {      "object": "role",      "id": "role_c40a95f21cc65d31c2a89e07",      "name": "Viewer",      "description": "Reads the mail on the addresses they hold, and changes nothing.",      "permissions": [        "emails:read",        "drafts:read",        "threads:read",        "labels:read",        "contacts:read",        "calendar:read",        "templates:read",        "rules:read",        "connections:read",        "settings:read"      ],      "builtin": "viewer",      "editable": true,      "deletable": true,      "members": 3,      "apiKeys": 1,      "createdAt": "2026-08-01T09:00:00.000Z",      "updatedAt": "2026-08-01T09:00:00.000Z"    }  ],  "hasMore": false,  "nextCursor": null}

Sorted by built-in rank then name (owner, admin, member, viewer, developer, billing, then the rest alphabetically) rather than newest-first like the rest of the API. A permission matrix is read as a ladder, and sorting it by createdAt puts the widest role in a different row every week.

Reading this list is what SEEDS the six on a workspace that has never had any. The seeding conflicts on a unique index and does nothing the second time, so the call is idempotent and only the first one writes, which is also why POST /members can always name a roleId that exists.

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 again, which is what makes deleting a seeded role permanent. An earlier build re-inserted whatever template row was missing on every read, so a deleted Billing came back under a new id on the next page load; it does not any more.

members and apiKeys are what would have to be moved before the role could go, which is what lets a client warn before offering the delete rather than after the 409. The owner row usually reads members: 0: the owner is not a member of their own workspace, they are the account it is keyed on.

There is a hard ceiling of 24 custom roles precisely so this can be one response. A workspace with forty roles cannot answer “who can send as billing@” by looking, which is the only question the feature exists to make answerable.