---
title: "Grant and revoke an address"
description: "The second axis: which addresses one person may reach, and at which level."
url: "https://openemail.uk/docs/api/members/addresses"
area: "API"
category: "Roles & access"
---

# Grant and revoke an address

The second axis: which addresses one person may reach, and at which level.

`POST /members/{userId}/addresses`

**Also documents:** `DELETE /members/{userId}/addresses/{addressId}`

## POST /members/{userId}/addresses

The second axis: which addresses one person may reach, and at which level.

## A grant is not a role

`access` is the older per-address vocabulary and deliberately does not overlap with the permission names: `member` reads the address and sends as it, `viewer` only reads it. It says nothing about whether the person may send at all, which is their role, and both have to allow a send before one happens.

| Their role | Their grant on billing@ | Can they send as billing@ |
| --- | --- | --- |
| holds `emails:send` | `member` | Yes. |
| holds `emails:send` | `viewer` | No, the grant refuses it. |
| no `emails:send` | `member` | No, the role refuses it. |
| holds `emails:send` | no grant at all | No, the address never enters the list a send is checked against. |

> Giving somebody a role gives them no addresses. A member with a role and no grants opens an EMPTY mailbox rather than everybody’s, which is the right failure to have while you are still deciding what they should see.

## Grant an address

Needs `members:write`. `POST /members/{userId}/addresses` with `{ addressId, access }`; `access` defaults to `member`. Returns the whole member as they now stand.

**curl**

```
curl -X POST "$OE/members/nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t/addresses" -H "$AUTH" \
    -H "Content-Type: application/json" \
    -d '{ "addressId": "c40a95f2-1cc6-4d31-82a8-9e075d31c2a8", "access": "viewer" }'
```

**Response**

```
{
    "object": "member",
    "userId": "nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t",
    "email": "sam@acme.com",
    "name": "Sam Okonjo",
    "image": null,
    "role": {
      "id": "role_2b81de079c1f0a4b7e05d386",
      "name": "Support",
      "builtin": null
    },
    "implied": false,
    "permissions": ["emails:send", "emails:read", "threads:read", "threads:write"],
    "addresses": [
      {
        "addressId": "2b81de07-9c1f-4a4b-8e05-d3862c1f0a44",
        "address": "help@acme.com",
        "access": "member"
      },
      {
        "addressId": "c40a95f2-1cc6-4d31-82a8-9e075d31c2a8",
        "address": "billing@acme.com",
        "access": "viewer"
      }
    ],
    "createdAt": "2026-08-12T14:20:00.000Z"
  }
```

> `POST` on the sub-collection rather than a `PUT` on the pair, because it is an upsert either way and the row id is not something a caller ever names. Re-posting with a different `access` is how a viewer becomes a member: there is one row per (address, person), so a second call changes the level rather than adding a second grant. That also makes this the rare POST that is safe to repeat.

> Gated on `members:write` rather than on owning the address, which is the difference between this and the older grant path on the domains router. 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’s access on the owner’s behalf. Both write the same row.

> The whole member comes back rather than the grant alone, so the row on screen can be re-rendered without a second request, and so the answer reads the same whether the grant was new or an amendment.

> An address that is not on this workspace is `member_not_found`, a 422, carrying `param: "addressId"`. A key can only hand out addresses belonging to the workspace it was issued against.

> Granting to the workspace OWNER is `member_is_owner`, a 422. They already have every address on it, so there is nothing the call could add.

## Revoke an address

Needs `members:write`. `DELETE /members/{userId}/addresses/{addressId}`. Returns the member, minus that address.

**curl**

```
curl -X DELETE \
    "$OE/members/nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t/addresses/c40a95f2-1cc6-4d31-82a8-9e075d31c2a8" \
    -H "$AUTH"
```

**Response**

```
{
    "object": "member",
    "userId": "nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t",
    "email": "sam@acme.com",
    "name": "Sam Okonjo",
    "image": null,
    "role": {
      "id": "role_2b81de079c1f0a4b7e05d386",
      "name": "Support",
      "builtin": null
    },
    "implied": false,
    "permissions": ["emails:send", "emails:read", "threads:read", "threads:write"],
    "addresses": [
      {
        "addressId": "2b81de07-9c1f-4a4b-8e05-d3862c1f0a44",
        "address": "help@acme.com",
        "access": "member"
      }
    ],
    "createdAt": "2026-08-12T14:20:00.000Z"
  }
```

> The narrow revocation, and the one to reach for when somebody moves team: they keep their role and their other addresses and stop seeing this one.

> An address that is not on this workspace is refused rather than silently ignored. A typo in the id would otherwise report a successful revocation that never happened, which is the 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.

> To take back everything at once, `DELETE /members/{userId}` removes the role and every grant together and reports how many went.
