Members and Invitations
Manage project members, roles, and invitations
Projects are collaborative. Anybody holding members:write adds people by sending email invitations, and once accepted
the invitee becomes a member carrying the role
named at invitation time - or the
project’s default role, when none was named.
These routes address the project by path id, so the X-Mailyard-Project-Id header is not used here.
These take a session, not an API key
The routes on this page address a project by path id and read the caller’s membership, which an API key does not have - it is refused on all of them. Do this work in the console, or with a signed-in session.
Members
List members
GET /api/v1/projects/{id}/members
Response:
{
"members": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "b8493407-52ae-4d51-8396-9c8864977976",
"name": "Ada Lovelace",
"email": "ada@example.com",
"owner": true,
"role_id": "0f7c1c62-2b3e-4a55-9f2e-6d0e2c9b1a44",
"role_name": "Support",
"inherited_role": false,
"created_at": "2026-05-31T10:00:00Z"
}
]
}
inherited_role says the member carries the project’s default rather than a role of their own - the difference matters,
because only one of the two survives a change to the default.
Update a member’s role
PATCH /api/v1/projects/{id}/members/{user_id}
{id} is the project and {user_id} is the member’s user id (the user_id field returned by the list endpoint).
{
"role_id": "0f7c1c62-2b3e-4a55-9f2e-6d0e2c9b1a44"
}
role_id names one of the project’s own roles. Send "role_id": "" to clear it, which drops the member back to the
project’s default role - and to nothing at all if the project has named none. A role id that does not exist in this
project answers 404, the same answer a role belonging to another project gets.
You may only hand out what you hold yourself. A role granting a permission you lack answers 403, and so does clearing
a role when the project’s default role grants one - the member would inherit it.
The same endpoint grants and revokes ownership:
{
"owner": true
}
Ownership is not a role and is gated harder than one. members:write hands out roles, and roles are bounded by the
permission catalogue - ownership is the ability to delete the project, so only an existing owner may grant or revoke it
(403 otherwise). A project must keep at least one owner: demoting the last one answers 409, and nothing in that
request is applied, the role included.
Roles themselves are managed at GET/POST /api/v1/projects/{id}/roles and
PATCH/DELETE /api/v1/projects/{id}/roles/{roleId}, and the project’s default role at
PUT /api/v1/projects/{id}/default-role. The permission catalogue the checkboxes render from is
GET /api/v1/permissions.
Remove a member
DELETE /api/v1/projects/{id}/members/{user_id}
Removes the member and returns 204. Any member may remove themself. Removing somebody else needs members:delete. A
project’s last owner cannot be removed, by themself or by anybody (409) - a project with no owner could never be
deleted or handed on.
Invitations (project side)
Outgoing invitations live under /api/v1/projects/{id}/invitations and need members:write.
Invite a member
POST /api/v1/projects/{id}/invitations
{
"email": "newuser@example.com",
"role_id": "0f7c1c62-2b3e-4a55-9f2e-6d0e2c9b1a44"
}
Only email is required. Leaving role_id out offers the project’s default role, which is how most people are meant to
be invited. There is no way to invite somebody as an owner - ownership is granted afterwards, deliberately, by an owner.
The role offered, named or default, is bounded like any other assignment: one granting a permission you do not hold
answers 403. Inviting an address that already belongs to a member is accepted, and redeeming it changes nothing for
them (see below).
A pending invitation is created with a unique token, valid for 7 days, and an invitation email is sent to the
address with an accept link of the form /<app>/invitations?token=<token>.
Every invitation is a mail from the platform’s own sender, so a project’s invitations are budgeted:
- An address holds one pending invitation per project. Inviting it again while that one is unaccepted and unexpired
answers
409and sends nothing. Cancel the pending invitation first to issue a new one. - An address is sent at most one invitation an hour per project. Cancelling an invitation does not give its address
a fresh one: inviting it again within the hour answers
429. - A project creates at most 50 invitations an hour and holds at most 200 pending ones. Cancelled invitations
still count toward the hourly limit. Past either limit the request answers
429.
Response (201):
{
"invitation": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"project_id": "81af718e-f0ae-4780-a0d7-9f05b34dabcc",
"project": "",
"email": "newuser@example.com",
"role_id": "0f7c1c62-2b3e-4a55-9f2e-6d0e2c9b1a44",
"role_name": "Support",
"status": "pending",
"expires_at": "2026-06-07T10:00:00Z",
"created_at": "2026-05-31T10:00:00Z"
}
}
List pending invitations
GET /api/v1/projects/{id}/invitations
Returns the project’s invitations as an array of the object shown above - pending ones and past ones alike, with
status saying which (pending, accepted, declined).
Cancel an invitation
DELETE /api/v1/projects/{id}/invitations/{invId}
Deletes the pending invitation by its ID and returns 204.
Invitations (invitee side)
The recipient redeems an invitation with the token from the invitation mail. Both routes need a signed-in session and no project header - the invitee is not a member of anything yet.
POST /app/api/invitations/{token}/accept
POST /app/api/invitations/{token}/decline
Neither takes a body: the token in the path is the whole claim.
On accept the invitee joins the project carrying the invited role - or the project default, if the invitation named none
or named a role that has since been deleted - and the invitation is marked accepted. Somebody who is already a member
keeps the role they have: an invitation is an offer to join, not the moment to overwrite a role somebody set
deliberately, and the invitation is marked accepted with 200 and their existing membership in the response. The
invitation’s address must match the signed-in account, otherwise 403. A token that is unknown or already used answers
404, an expired one 410.
Accepting or declining is recorded in the audit trail of the project the invitation came from.
There is no list-my-invitations endpoint
An invitation is redeemed from the link in its mail, or from the copyable link the project admin sees after creating it. Nothing enumerates the invitations addressed to you, and there is no accept-by-id route - the token is the only way in.