Invite someone to the workspace at a named role
Invite one address at one role, or resend if one is already pending.
/v1/workspace/invitationsAdmin- Operation id
- issue_invitation
- Access
- An editor or a viewer is refused.
/v1/workspace/invitationsInvite one address at one role, or resend if one is already pending.
Sends the invitation email and returns the record. The email carries the only copy of the link: the token is not in this response, is not in the list, and cannot be fetched later. If it does not arrive, resend.
role is required and has no default, because the role is named in the
email the invitee reads. You cannot invite anyone above your own role.
Inviting an address that already has a pending invitation is a resend,
not a conflict. It rotates the token, takes the role and issuer from this
request, and resets the expiry. resent in the response says which of the
two happened. delivery reports what became of the message: a failed
state with last_error means the record exists and the mail did not go, so
the fix is to resend rather than to invite again.
The response is the same shape whether or not the address already has an account here. That is deliberate and nothing in it can be read to find out.
Issuing is rate limited per workspace, and a resend is charged as an issue.
assert_may_grant is the existing escalation rule and is reused verbatim
(FR-88): nobody may invite above their own role, so an invitation can never
be a route around the role control. That is also the first of the two
guards FR-121's promote-on-acceptance ships with, and the reason a promotion
at acceptance cannot exceed what its issuer held when they chose it.
One statement, upserted onto the pending slot. 0073's partial unique
index over (tenant_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL is inferred here as the conflict target, so re-inviting a pending
address is a resend (FR-90) atomically rather than through a
SELECT-then-INSERT two admins can both pass.
A resend takes the new request's role and issuer rather than keeping the
old ones. Both are deliberate: the admin has just stated a role and
assert_may_grant has just bound it, so keeping the old one would grant
something nobody asked for; and FR-101 re-reads the issuer's authority live
at acceptance, so the issuer recorded has to be the person who most recently
chose this invitation, not whoever happened to be first.
Nothing here reads users. See the module docstring: FR-89 is a
property of the query that is not written.
Tenancy: the request is scoped to one workspace, and the credential presented is what selects it. A caller with no membership in that workspace is refused with 404, which is indistinguishable from a workspace that does not exist.
Authorisation: admin or owner. An editor or viewer receives 403 insufficient_role.
Access: Authorization: Bearer carrying either a realm access token or an API key. A key must hold the invitations:write scope, and the workspace role of the person who owns it must meet the floor above.
A realm-issued access token, presented as Authorization: Bearer <token>. It acts as the person who signed in, and every operation resolves their workspace role live from the membership record.
In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
curl -X POST "https://example.com/v1/workspace/invitations" \ -H "Content-Type: application/json" \ -d '{ "email": "sam.okafor@northwind.example", "role": "editor" }'{ "id": "3a9c7e12-58bd-4f06-9a44-2c7e1b0d8f31", "email": "sam.okafor@northwind.example", "role": "editor", "state": "pending", "expires_at": "2026-08-21T10:04:00+00:00", "created_at": "2026-08-14T10:04:00+00:00", "invited_by": { "id": "c0f4a7d3-1b62-4e58-8d90-6f3a2c5e7b18", "display_name": "Dana Osei", "email": "dana.osei@northwind.example" }, "delivery": { "state": "sent", "attempts": 1, "message_id": "0100019180a4c1f2-9f0a4a2e-1e4c-4d31-9a77-5b8c2d1e0f44-000000", "last_sent_at": "2026-08-14T10:04:01+00:00" }, "resent": false}