Send an invitation
POST/api/organisations/:id/invitations
Send an invitation. With a projectId this invites an external collaborator onto that one project, accepts either projects.collaborators.manage or org.invitations.manage, and additionally requires that the caller can reach the project. Without a projectId it adds a member to the organisation and requires org.invitations.manage alone.
Request
Responses
- 201
- 400
- 401
- 403
- 404
- 409
- 500
Successful response
Bad Request — email is not an email address, projectId / projectRoleId / roleId is not UUID-shaped (an empty projectId included, so it cannot silently fall through to the org-level branch), or an org-level invitation omits roleId
Unauthorized — no authenticated user
Forbidden — a project-scoped invitation requires projects.collaborators.manage or org.invitations.manage plus access to the target project (an inaccessible, non-existent, or other-tenant project is refused uniformly); an org-level invitation requires org.invitations.manage. A rejected x-act-as-org selector is also a 403, thrown as OrgOverrideForbiddenError and therefore carrying the nested envelope
Not Found — a supplied role does not belong to this organisation, or the tenant has no 'Guest Customer' role. The second case belongs to the PROJECT-SCOPED branch only: that branch resolves the role unconditionally as the organisation role the invitation will carry, so its absence is a 404 even when an explicit projectRoleId is supplied — the lookup is not a fallback that a supplied role skips. It is additionally the fallback for the PROJECT role. The org-level branch never looks it up: it validates the roleId it was given and nothing else. Note the precedence: an org-level invitation validates roleId, while a project-scoped one validates only the role it will actually use — projectRoleId when present, otherwise roleId — so a roleId that is overridden by a valid projectRoleId is never checked
Conflict. The cases are per-branch, and one of them is deliberately NOT a conflict: on the project branch an invitee who is already an organisation member is added as a collaborator and returns 201, so only the org-level branch rejects that. Both branches — a still-PENDING invitation already exists for this email (and project). Note that this is NOT limited to a redeemable one: expiry is lazy, so a row can sit at PENDING long past its expiry and still conflict, on the org-level branch and on the project branch alike. Both duplicate checks exist to stop the insert colliding with the invitation unique key, and narrowing them to redeemable rows would turn this 409 into a unique-violation 500; recycling a stale invitation is a documented follow-up. Only the guard that gates the project branch GRANT paths is redeemable-scoped, so an expired token cannot block a legitimate grant. Org-level branch only — the invitee is already a member of the organisation. Project branch only — the invitee is already an ACTIVE collaborator on the project; the invitation asks for a different project role than an already-ACTIVE collaborator row holds; or a conflicting collaborator record exists that this organisation does not own (deliberately non-specific, so no response discloses cross-tenant state)
Internal Server Error. Listed because the project branch has DELIBERATE 500 outcomes, not merely the catch-all every route has: the raw-SQL reads behind assertProjectInTenant and the collaborator upsert are parsed rather than cast (PT-888), so a result whose shape does not match the statement raises UNEXPECTED_ROW_SHAPE instead of being coerced into a tenancy decision. The message names which read drifted and nothing more — field-level detail stays in the server log. Not retryable; it means this module and its database disagree.