Skip to the documentation
API

Grant and revoke an address

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

POST/members/{userId}/addresses

Runs any of 2 calls on your workspace.

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 roleTheir grant on billing@Can they send as billing@
holds emails:sendmemberYes.
holds emails:sendviewerNo, the grant refuses it.
no emails:sendmemberNo, the role refuses it.
holds emails:sendno grant at allNo, the address never enters the list a send is checked against.
holds emails:send and addresses:allany grant, or noneYes. addresses:all reaches every address, including ones added later, so the grant is not consulted.

Giving somebody a role gives them no addresses, unless the role holds addresses:all. A member with any other 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.

An OAuth access token needs a verification code for this call. Until the app has verified one in the last 60 minutes, the call answers 403 step_up_required and changes nothing. An API key is never asked. The Authentication page shows how to ask for a code and verify it.

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.

An OAuth access token needs a verification code for this call. Until the app has verified one in the last 60 minutes, the call answers 403 step_up_required and changes nothing. An API key is never asked. The Authentication page shows how to ask for a code and verify it.

Reference