---
title: "Members"
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/members"
area: "API"
category: "Reference"
---

# Members

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

## Operations

Who is in this workspace, and what each of them can reach.

A member is two grants and not one. A ROLE says what they may do; a set of ADDRESS grants says what they may do it to, each carrying `member` (read the address and send as it) or `viewer`, which only reads. Both are checked before mail goes out, so a screen showing one of them will explain the wrong refusal with great confidence.

`implied: true` means nobody chose the role. Sharing shipped long before roles did and a great many people still have address grants and no membership row; rather than lock them out until a backfill has run, the widest grant they hold names a built-in. A `PATCH` is what turns that inference into a decision, and until it happens, widening their addresses widens what they may do.

Everybody joins by invitation: `POST /members` invites, `GET /members/invitations` lists the invitations still waiting, and they can be sent again or withdrawn. A waiting invitation grants nothing until it is accepted. The owner is the first row of `GET /members`, and holds everything by definition.

### `GET /members`

List members

Everybody with access to this workspace, the owner first and the rest by email. A page at a time: follow `nextCursor` while `hasMore` is true to read everybody.

Each row carries BOTH axes and a client must not collapse them. `role` is what somebody may do; `addresses` is what they may do it to, one entry per address with its own `access`. Someone with `emails:send` and an empty `addresses` may send from nothing, and someone holding every address under a viewer role may send from none of them either. The send path checks both, so a screen showing one of them will confidently explain the wrong refusal.

The one exception is `addresses:all`. Somebody whose `permissions` hold it reaches every address on every domain of the workspace, including ones added later, and sends as any of them when `permissions` also holds `emails:send`, whatever `addresses` and `domains` list. Those two still hold only direct grants, which such a member may have kept from before or been given since, or none at all. Read reach from `permissions` first, and from those arrays only when it lacks `addresses:all`.

Watch for `implied: true`. It means nobody chose that role: sharing shipped long before roles did, so a great many people have address grants and no membership row, and the widest grant they hold names a built-in rather than leaving them locked out until a backfill has run. A `PATCH` is what turns the inference into a decision.

The owner IS in this list, as the first row, marked `isOwner: true`. They are the thing the workspace is keyed on, they hold every permission by definition, and `POST`, `PATCH` and `DELETE` all refuse them with `member_is_owner`. Exclude `isOwner` when you are counting seats.

Requires the `members:read` scope.

- Scopes: `members: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` `MemberList`: A page of people with access, by email.

**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 [`members.list()`](https://openemail.uk/docs/sdk/reference/members#list), [`members.listAll()`](https://openemail.uk/docs/sdk/reference/members#listAll), [`members.iterate()`](https://openemail.uk/docs/sdk/reference/members#iterate); CLI [`openemail members list`](https://openemail.uk/docs/cli/reference/members#members-list); MCP [`listWorkspaceMembers`](https://openemail.uk/docs/mcp/tools/workspace#listWorkspaceMembers).

### `POST /members`

Invite a member

Invites somebody to the workspace. Whether or not the address already has an OpenEmail account, the answer is an invitation and a 202, never a member: nobody is put into a workspace without accepting, and this endpoint is held to the same rule as the app.

The invitation carries the role, the addresses and the whole domains you name, and grants exactly those the moment it is accepted. Nothing is granted before that. An address invited in the last ten minutes answers `invitation_too_soon`, and asking twice refreshes the one invitation rather than sending two.

Somebody already in the workspace is refused with `member_is_owner` (the closest existing code). Change what an existing member may do with `PATCH /members/{userId}` and the address and domain grant calls, which only work on people already in.

`invitedBy` is left null deliberately. The column records which PERSON invited somebody, and a key is not a person.

Remember which axis this sets. A role does not reach an address, and the addresses do not widen the role. The one exception is a role holding `addresses:all`, which reaches every address with no ids at all.

The authority of a key is, in the same way, its own scopes intersected with the role it was issued under, so a role holding a permission the key lacks is refused with `insufficient_authority`, a 403. No key or token holds a console-only permission, so a role with one of those in it, `addresses:all` included, is handed out in the app.

Requires the `members:write` scope.

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

**Request body**

- `email` (`string`, required, format `email`): Who to invite. Lower-cased on the way in. It does not matter whether an OpenEmail account exists behind it: everybody is invited, and nobody is in the workspace until they accept.
- `roleId` (`string`, required): From `GET /roles`. The owner role is refused: a workspace transfer is not this.
- `addressIds` (`string[]`, up to 64 items): Addresses the invitation carries, all at the same `access`. They are granted the moment the person accepts, so "invite Sam as Support on help@" stays one intention.
- `domainIds` (`string[]`, up to 64 items): Whole domains the invitation carries, at the same `access`. A domain grant reaches every address on it, including ones made later.
- `access` (`string`, one of `"member"`, `"viewer"`, default `"member"`): `member` reads the address and sends as it; `viewer` only reads it. The second axis, ANDed with the role. This cannot widen what a role does not already allow.

**Returns**

- `202` `Invitation`: Invited. They are in the workspace once they accept.

**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.
- `404`: `role_not_found` for the `roleId`, or an `addressIds` / `domainIds` entry that is not on this workspace.
- `409`: `invitation_too_soon` (that address was invited in the last ten minutes) or `invitation_limit_reached`.
- `422`: `member_is_owner`: the address is already in this workspace, or is the owner. Use PATCH to change what an existing member may do.
- The errors every operation can return: `400`, `401`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

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

### `GET /members/{userId}`

Retrieve a member

Read off the same union the list computes, rather than by a query of its own. A member is the union of two populations (a membership row and a set of address grants, either of which exists perfectly well without the other), and a single-row lookup would be a second implementation of that union whose blind spot is precisely the legacy grant holders, who are most of the table today.

Requires the `members:read` scope.

- Scopes: `members:read`.

**Path parameters**

- `userId` (`string`, required): The ACCOUNT id, which is what `GET /members` returns and not the email address. An email identifies a person to a human and is the wrong key here: it can be changed on the account, and a caller holding a stale one would quietly address the wrong grant.

**Returns**

- `200` `Member`: The member, with their role and their addresses.

**Errors**

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

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

### `PATCH /members/{userId}`

Change a member's role

Changes the role and nothing else, for somebody ALREADY in the workspace. It is also how a legacy grant holder stops being `implied`: they have address or domain grants and no membership row, this writes one, and from then on their permissions are what somebody chose rather than what their access happened to imply. Their grants are untouched.

A role holding a permission the key lacks is refused with `insufficient_authority`, a 403. No key or token holds a console-only permission, so moving somebody onto a role with `addresses:all` in it, which would reach every address for them without a single grant, is done in the app.

Addresses are deliberately not patchable from here. They are a set with a per-element access level, and an array on a PATCH would have to mean replace-whole: a silent mass revocation every time a client sends a list it read five minutes ago. The two address endpoints below move one at a time, so what happened is always what was asked for.

The owner role cannot be handed out. Making somebody an owner is a workspace transfer, which has entirely different consequences and is not an operation this API has.

Requires the `members:write` scope.

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

**Path parameters**

- `userId` (`string`, required): The ACCOUNT id, which is what `GET /members` returns and not the email address. An email identifies a person to a human and is the wrong key here: it can be changed on the account, and a caller holding a stale one would quietly address the wrong grant.

**Request body**

- `roleId` (`string`, required): The role to move them to. Their addresses are untouched by this.

**Returns**

- `200` `Member`: Saved. `implied` is false from here on.

**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_immutable`: the owner role cannot be handed out.
- `422`: `member_is_owner`, or `member_not_found` when the account is not in this workspace yet. Nobody joins through PATCH: invite them with `POST /members` and they are in once they accept.
- The errors every operation can return: `400`, `401`, `404`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

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

### `DELETE /members/{userId}`

Remove a member

Takes somebody out of the workspace entirely: the membership row AND every address grant they hold here. Removing only the first would be the worst of both worlds. They would vanish from the list and go on reading the mail.

Idempotent, and it does NOT 404 on somebody who is not a member. The workspace owner is the one id it refuses, with 422 `member_is_owner`. That is not laxness about ids: the population this endpoint most needs to reach is the legacy grant holders, who have address grants and no membership row at all, so a pre-flight existence check would refuse exactly the people whose access most wants revoking. `addressesRevoked` reports what actually happened.

It does not touch their account, their sent mail or anything they wrote. It removes their access to this workspace.

Requires the `members:write` scope.

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

**Path parameters**

- `userId` (`string`, required): The ACCOUNT id, which is what `GET /members` returns and not the email address. An email identifies a person to a human and is the wrong key here: it can be changed on the account, and a caller holding a stale one would quietly address the wrong grant.

**Returns**

- `200` `object`: Removed, with the number of address grants that went with them.
  - `object` (`string`, one of `"member"`)
  - `userId` (`string`)
  - `deleted` (`boolean`, one of `true`)
  - `addressesRevoked` (`integer`): Grants actually removed. Zero is the honest report of a no-op.

**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.
- 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 [`members.remove()`](https://openemail.uk/docs/sdk/reference/members#remove); CLI [`openemail members remove`](https://openemail.uk/docs/cli/reference/members#members-remove); MCP [`removeMember`](https://openemail.uk/docs/mcp/tools/workspace#removeMember).

### `POST /members/{userId}/addresses`

Grant an address

Hands one person one address, or changes what they may do with one they already have. An upsert: there is one row per address-and-person pair, so re-posting with a different `access` promotes a viewer to a member rather than adding a second grant.

A `POST` to the sub-collection rather than a `PUT` on the pair, because the row has no id a caller ever names.

This is the SECOND axis and it cannot widen the first: `access: "member"` on somebody whose role lacks `emails:send` does not let them send, it lets them read. Both have to agree before a message goes out.

Gated on `members:write` rather than on owning the address, which is where it differs from the older sharing path in the console. Ownership is the right gate for the person who put the domain in and the wrong one for an admin who owns nothing and is running the workspace access on behalf of the owner. Both write the same row.

The whole member comes back rather than the grant alone, so a client can re-render the row it is looking at without a second request, and so the answer reads the same whether the grant was new or an amendment.

Requires the `members:write` scope.

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

**Path parameters**

- `userId` (`string`, required): The ACCOUNT id, which is what `GET /members` returns and not the email address. An email identifies a person to a human and is the wrong key here: it can be changed on the account, and a caller holding a stale one would quietly address the wrong grant.

**Request body**

- `addressId` (`string`, required): An address on this workspace.
- `access` (`string`, one of `"member"`, `"viewer"`, default `"member"`): `member` reads the address and sends as it; `viewer` only reads it. The second axis, ANDed with the role. This cannot widen what a role does not already allow.

**Returns**

- `200` `Member`: The whole member, with the grant applied.

**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.
- 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 [`members.grantAddress()`](https://openemail.uk/docs/sdk/reference/members#grantAddress); CLI [`openemail members grant-address`](https://openemail.uk/docs/cli/reference/members#members-grant-address); MCP [`grantMemberAddress`](https://openemail.uk/docs/mcp/tools/workspace#grantMemberAddress).

### `DELETE /members/{userId}/addresses/{addressId}`

Revoke an address

Takes one address back and leaves the person in the workspace, with their role and their other addresses. The narrow revocation, and the one to reach for when somebody moves team.

An address that is not on this workspace is refused rather than quietly ignored: a typo in the id reporting a successful revocation that never happened is the exact failure this endpoint exists to prevent.

The member comes back rather than a tombstone, because the interesting answer is what they can still reach. A `{ deleted: true }` here would leave a client to work that out by subtraction.

Requires the `members:write` scope.

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

**Path parameters**

- `userId` (`string`, required): The ACCOUNT id, which is what `GET /members` returns and not the email address. An email identifies a person to a human and is the wrong key here: it can be changed on the account, and a caller holding a stale one would quietly address the wrong grant.
- `addressId` (`string`, required): An address on THIS workspace. One belonging to another is refused, not ignored.

**Returns**

- `200` `Member`: The member, with what they can still reach.

**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.
- 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 [`members.revokeAddress()`](https://openemail.uk/docs/sdk/reference/members#revokeAddress); CLI [`openemail members revoke-address`](https://openemail.uk/docs/cli/reference/members#members-revoke-address); MCP [`revokeMemberAddress`](https://openemail.uk/docs/mcp/tools/workspace#revokeMemberAddress).

### `GET /members/invitations`

List pending invitations

The invitations to this workspace that are still waiting, by email: the role and the addresses and whole domains each will grant once accepted, when it expires, and whether the last email reached them. A page at a time: follow `nextCursor` while `hasMore` is true.

A waiting invitation grants nothing. It becomes access only at the moment somebody accepts it, which is why it is listed here rather than in `GET /members`. An expired one stays on this list with `expired: true` until it is sent again or withdrawn.

A key limited to particular addresses or domains lists only the invitations that grant nothing outside them.

Requires the `members:read` scope.

- Scopes: `members: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` `InvitationList`: A page of invitations nobody has accepted yet, by email.

**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 [`members.listInvitations()`](https://openemail.uk/docs/sdk/reference/members#listInvitations), [`members.listAllInvitations()`](https://openemail.uk/docs/sdk/reference/members#listAllInvitations), [`members.iterateInvitations()`](https://openemail.uk/docs/sdk/reference/members#iterateInvitations); CLI [`openemail members list-invitations`](https://openemail.uk/docs/cli/reference/members#members-list-invitations); MCP [`listInvitations`](https://openemail.uk/docs/mcp/tools/workspace#listInvitations).

### `DELETE /members/invitations/{invitationId}`

Withdraw an invitation

Withdraws an invitation nobody has accepted. Its link stops working at once and nothing it would have granted is granted. Inviting the same address later with `POST /members` sends a new one.

A key limited to particular addresses or domains can withdraw only an invitation that grants nothing outside them. Any other answers 404.

Requires the `members:write` scope.

- Scopes: `members:write`.

**Path parameters**

- `invitationId` (`string`, required): The invitation id from `GET /members/invitations`, `winv_` and 24 hex.

**Returns**

- `200` `object`: Withdrawn. The link no longer works.
  - `object` (`string`, one of `"invitation"`)
  - `id` (`string`)
  - `email` (`string`)
  - `revoked` (`boolean`, one of `true`)

**Errors**

- `404`: No invitation with that id is waiting: it is unknown, or already withdrawn.
- `409`: `invitation_accepted`: it was accepted first. Remove the member instead.
- The errors every operation can return: `400`, `401`, `403`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`members.revokeInvitation()`](https://openemail.uk/docs/sdk/reference/members#revokeInvitation); CLI [`openemail members revoke-invitation`](https://openemail.uk/docs/cli/reference/members#members-revoke-invitation); MCP [`revokeInvitation`](https://openemail.uk/docs/mcp/tools/workspace#revokeInvitation).

### `POST /members/invitations/{invitationId}/resend`

Send an invitation again

Sends a waiting invitation again: a new link, fourteen more days, and the old link retired, so only the newest email works. It renews an expired invitation too. `delivered` and `deliveryError` describe this send.

The per-person daily allowance is counted against the person who sent the invitation first when a key asks, and against the person an app acts for when an app does.

The invitation's role cannot hold more than the caller does, the same rule as `POST /members`: a role with a permission the key or token lacks, any console-only one included, is `insufficient_authority`, a 403. An app acting for a member is also refused with `insufficient_authority` when the invitation carries addresses or domains that member does not reach.

Requires the `members:write` scope.

- Scopes: `members:write`.

**Path parameters**

- `invitationId` (`string`, required): The invitation id from `GET /members/invitations`, `winv_` and 24 hex.

**Returns**

- `200` `Invitation`: Sent again, with a new link and a new expiry.

**Errors**

- `403`: `insufficient_authority`: the invitation's role holds a permission the caller does not, or an app acting for a member would send again addresses or domains that member does not reach.
- `404`: No invitation with that id is waiting.
- `409`: `invitation_too_soon` (that address was sent an invitation in the last ten minutes) or `invitation_limit_reached`.
- The errors every operation can return: `400`, `401`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`members.resendInvitation()`](https://openemail.uk/docs/sdk/reference/members#resendInvitation); CLI [`openemail members resend-invitation`](https://openemail.uk/docs/cli/reference/members#members-resend-invitation); MCP [`resendInvitation`](https://openemail.uk/docs/mcp/tools/workspace#resendInvitation).

### `POST /members/{userId}/domains`

Grant a domain

Hands one person a whole domain: every address on it, including addresses added after the grant. An upsert, like `POST /members/{userId}/addresses`: posting again with a different `access` changes the grant rather than adding a second one.

It is the address axis, so it cannot widen the role: `access: "member"` lets somebody send only if their role also holds `emails:send`. Somebody who is not in the workspace yet is refused, so invite them first, and the owner is refused because they already reach every domain. A key or an app limited to particular addresses or domains is refused, and an app acting for a member can only give a domain that member reaches. Adding people needs a plan with team access, so a workspace without it is refused with 403 `plan_required`.

Requires the `members:write` scope.

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

**Path parameters**

- `userId` (`string`, required): The ACCOUNT id, which is what `GET /members` returns and not the email address. An email identifies a person to a human and is the wrong key here: it can be changed on the account, and a caller holding a stale one would quietly address the wrong grant.

**Request body**

- `domainId` (`string`, required): A domain on this workspace.
- `access` (`string`, one of `"member"`, `"viewer"`, default `"member"`): `member` reads the address and sends as it; `viewer` only reads it. The second axis, ANDed with the role. This cannot widen what a role does not already allow.

**Returns**

- `200` `Member`: The whole member, with the grant applied.

**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.
- 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 [`members.grantDomain()`](https://openemail.uk/docs/sdk/reference/members#grantDomain); CLI [`openemail members grant-domain`](https://openemail.uk/docs/cli/reference/members#members-grant-domain); MCP [`grantMemberDomain`](https://openemail.uk/docs/mcp/tools/workspace#grantMemberDomain).

### `DELETE /members/{userId}/domains/{domainId}`

Revoke a domain

Takes a whole domain back and leaves the person in the workspace, with their role and their other grants. Addresses on the domain that were granted one by one stay granted. A domain that is not on this workspace is refused rather than quietly ignored, and the member comes back so the answer shows what they can still reach.

Requires the `members:write` scope.

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

**Path parameters**

- `userId` (`string`, required): The ACCOUNT id, which is what `GET /members` returns and not the email address. An email identifies a person to a human and is the wrong key here: it can be changed on the account, and a caller holding a stale one would quietly address the wrong grant.
- `domainId` (`string`, required): A domain on THIS workspace, as `GET /domains` returns it. One belonging to another is refused, not ignored.

**Returns**

- `200` `Member`: The member, with what they can still reach.

**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.
- 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 [`members.revokeDomain()`](https://openemail.uk/docs/sdk/reference/members#revokeDomain); CLI [`openemail members revoke-domain`](https://openemail.uk/docs/cli/reference/members#members-revoke-domain); MCP [`revokeMemberDomain`](https://openemail.uk/docs/mcp/tools/workspace#revokeMemberDomain).

### Objects

#### `Invitation`

`object`

- `object` (`string`, one of `"invitation"`)
- `id` (`string`)
- `email` (`string`): Lower-cased. The only address that can accept it.
- `role` (`object`)
  - `id` (`string`)
  - `name` (`string`)
  - `builtin` (`string`, nullable, one of `"owner"`, `"admin"`, `"member"`, `"viewer"`, `"developer"`, `"billing"`)
- `addresses` (`object[]`)
  - `addressId` (`string`)
  - `address` (`string`)
  - `access` (`string`, one of `"member"`, `"viewer"`)
- `domains` (`object[]`)
  - `domainId` (`string`)
  - `domain` (`string`)
  - `access` (`string`, one of `"member"`, `"viewer"`)
- `expiresAt` (`string`, format `date-time`)
- `expired` (`boolean`): The link no longer works. An expired invitation stays waiting until it is sent again, which renews it, or withdrawn.
- `lastSentAt` (`string`, format `date-time`): When the invitation email last went out, the first send included.
- `delivered` (`boolean`, nullable): Whether the invitation email left the mail server. False is worth surfacing: nothing reached them. Null when no outcome was recorded for the last send.
- `deliveryError` (`string`, nullable)
- `createdAt` (`string`, format `date-time`)

#### `InvitationList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Invitation[]`)
- `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.

#### `Member`

`object`

- `object` (`string`, one of `"member"`)
- `userId` (`string`): The account. This is what every path here takes, not the email address.
- `email` (`string`)
- `name` (`string`, nullable)
- `image` (`string`, nullable)
- `role` (`object`): The role they hold. `id` is null when nobody chose it. See `implied`.
  - `id` (`string`, nullable)
  - `name` (`string`)
  - `builtin` (`string`, nullable, one of `"owner"`, `"admin"`, `"member"`, `"viewer"`, `"developer"`, `"billing"`)
- `isOwner` (`boolean`): True on exactly one row, the account the workspace is keyed on. They sort first, hold every permission whatever their role row says, and POST, PATCH and DELETE all refuse them with member_is_owner. Exclude them when counting seats.
- `implied` (`boolean`): NOBODY CHOSE THIS ROLE. Sharing shipped long before roles did, so a great many people have address grants and no membership row at all; rather than deny them their mail until a backfill has run, the widest grant they hold names a built-in and that is what is reported. Show it as implied by access rather than as a decision. Until somebody PATCHes them, widening their addresses silently widens what they may do.
- `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"`): The resolved list, the same one `role.permissions` would give.
- `addresses` (`object[]`): Which addresses on this workspace they were granted, and how. Empty is a real answer and a common one: somebody with a role and no addresses can do a great deal to nothing at all. The exception is `permissions` holding `addresses:all`, which reaches every address on every domain of the workspace, including ones added later, whatever this lists. It still lists only the addresses granted directly, which such a member may have kept from before or been given since.
  - `addressId` (`string`)
  - `address` (`string`)
  - `access` (`string`, one of `"member"`, `"viewer"`): The older per-address vocabulary, deliberately not overlapping with the permission names: `member` reads the address and sends as it, `viewer` only reads it. ANDed with the role rather than added to it. `viewer` here refuses a send from somebody whose role holds `emails:send`, unless the role also holds `addresses:all`.
- `domains` (`object[]`): Whole domains they hold. A domain grant reaches every address on that domain, including ones created after the grant, at the given access. Addresses under a held domain also appear in `addresses` only when they were granted individually as well. Somebody whose `permissions` hold `addresses:all` reaches every domain whether or not one is listed here.
  - `domainId` (`string`)
  - `domain` (`string`)
  - `access` (`string`, one of `"member"`, `"viewer"`)
- `createdAt` (`string`, nullable, format `date-time`): When they were made a member. Null for the legacy grant holders, who never were.

#### `MemberList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Member[]`)
- `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.
