API Reference
API Reference
GitHub ↗
Projects

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 409 and 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.