---
title: "List roles"
description: "Every role on the workspace, built-ins first, with how many people and keys hold each."
url: "https://openemail.uk/docs/api/roles/list"
area: "API"
category: "Roles & access"
---

# List roles

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

`GET /roles`

## 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.uk
export 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.
