Grant and revoke an address
The second axis: which addresses one person may reach, and at which level.
Runs any of the 2 calls on this page against your workspace, with your own key.
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 -X POST "$OE/members/nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t/addresses" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "addressId": "c40a95f2-1cc6-4d31-82a8-9e075d31c2a8", "access": "viewer" }'{ "object": "member", "userId": "nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t", "email": "[email protected]", "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": "[email protected]", "access": "member" }, { "addressId": "c40a95f2-1cc6-4d31-82a8-9e075d31c2a8", "address": "[email protected]", "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 -X DELETE \ "$OE/members/nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t/addresses/c40a95f2-1cc6-4d31-82a8-9e075d31c2a8" \ -H "$AUTH"{ "object": "member", "userId": "nQ8vBz1aRd4tYwKx7fQ2mN8vBz1aRd4t", "email": "[email protected]", "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": "[email protected]", "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.