Bulk invite members
Invite up to 100 people to a workspace in one request. Every invitee gets the same role and the same optional message, and each one is emailed an invitation link.
This endpoint doesn't use the generic bulk-write shape
Despite the bulk/ path, this endpoint does not take {items: [...]} and does not return per-row results. Its body has its own fields (emails, role, message), and it returns 201 with the invitations it created. The bulk-create, bulk-update, and bulk-delete routes on other resources follow a different contract.
Path parameters
slug:requiredstringThe workspace slug. It appears in your Plane URLs. In https://app.plane.so/my-team/projects/, the slug is my-team.
Body parameters
emails:requiredarray of string (email)The addresses to invite, at most 100 per call. Each must be a valid, non-empty email address. One malformed entry makes the whole request return 400, and no invitations are sent.
role:optionalstringThe role every invitee receives. One of admin, member, or guest. Defaults to member.
You can't give invitees different roles in one call. Send one request per role.
message:optionalstringA note included in the invitation email, shown to every invitee in this batch.
Addresses with a pending invite are skipped without an error
Before writing, the endpoint checks which of your emails already have an outstanding invite to this workspace and leaves them out. It returns no error for them, creates no duplicate invite, and sends no second email. Those addresses don't appear in the response.
The response array lists exactly the invitations that were created. It can be shorter than the emails you sent, or empty. To report "already invited" back to a user, compare the two.
The batch is atomic
All the invitations are written inside a single transaction, so either every new invite is created or none is. Unlike the bulk-create routes on other resources, there is no partial-success envelope to inspect.
Scopes
workspaces.members:write
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | emails missing or empty, an invalid address, more than 100 entries, or a role outside the enum. |
401 | unauthorized | Missing or invalid credentials. |
402 | payment_required | Seat limits or a plan restriction block new members. |
403 | forbidden | Your role or token scope can't invite members to this workspace. |
404 | not_found | No such workspace, or it's outside your tenant. |
406 | not_acceptable | The Accept header asks for a representation the API can't produce. |
409 | conflict | The write collides with existing membership state. |
413 | payload_too_large | The request body is over the size limit. |
415 | unsupported_media_type | The Content-Type isn't one this endpoint accepts. |
429 | rate_limited | Throttled. Honor the Retry-After header before retrying. |
curl -X POST \
"https://api.plane.so/api/v2/workspaces/my-team/invitations/bulk/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"emails": ["ana@example.com", "ravi@example.com"],
"role": "member",
"message": "Joining the platform team this sprint."
}'import requests
response = requests.post(
"https://api.plane.so/api/v2/workspaces/my-team/invitations/bulk/",
headers={"X-Api-Key": "your-api-key"},
json={
"emails": ["ana@example.com", "ravi@example.com"],
"role": "member",
"message": "Joining the platform team this sprint.",
},
)
created = response.json()
print(f"{len(created)} invited")const response = await fetch("https://api.plane.so/api/v2/workspaces/my-team/invitations/bulk/", {
method: "POST",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
emails: ["ana@example.com", "ravi@example.com"],
role: "member",
message: "Joining the platform team this sprint.",
}),
});
const created = await response.json();
console.log(`${created.length} invited`);[
{
"id": "b7e42a19-3c5d-4f80-9a26-8d1c0f4e7b53",
"email": "ana@example.com",
"role": "member",
"message": "Joining the platform team this sprint.",
"accepted": false,
"responded_at": null,
"created_at": "2026-01-14T09:22:41.478363Z",
"updated_at": "2026-01-14T09:22:41.478363Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"updated_by_id": null
},
{
"id": "9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88",
"email": "ravi@example.com",
"role": "member",
"message": "Joining the platform team this sprint.",
"accepted": false,
"responded_at": null,
"created_at": "2026-01-14T09:22:41.478363Z",
"updated_at": "2026-01-14T09:22:41.478363Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"updated_by_id": null
}
]Tracking who accepted
The response gives you an invitation id per invitee, with accepted: false and responded_at: null. Poll List invitations to see which have been accepted, or Delete an invitation to revoke one that is still outstanding.
An invitation is not a membership. Accepted invitees show up in List workspace members.

