Invitations
An invitation grants a person access to one project. The only way an invited person learns the invitation token is the email sent to the invited address — no API response returns it.
Base URL
https://app.quazzar.spaceAll requests except POST /invites/accept/by-token require an Authorization: Bearer <token> header. See the Authentication guide.
The token is never in an API response
InviteOut — the response body of creating, resending, and listing invitations — does not carry a token field. It used to, which handed the invitation secret to anyone who could create, list, or resend an invitation. The token is a bearer capability for one mailbox and now reaches only that mailbox: POST /invites builds the accept URL server-side from the token and hands it to the email job.
This is load-bearing rather than cosmetic. The carve-out described under Accepting with a token is safe only because redeeming a token proves control of the invited mailbox, and that holds only while the token is unreadable from the API.
Create an Invitation
POST /invites
Requires the project owner role.
curl -X POST https://app.quazzar.space/invites \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_id": "550e8400-e29b-41d4-a716-446655440000",
"invited_email": "[email protected]",
"first_name": "Alex",
"last_name": "Dev",
"role": "viewer",
"environment_id": "env-uuid",
"group_id": "group-uuid",
"subgroup_id": "subgroup-uuid"
}'Only project_id and invited_email are required. role defaults to viewer at acceptance time.
Response (201 Created):
{
"id": "inv-uuid",
"project_id": "550e8400-e29b-41d4-a716-446655440000",
"invited_email": "[email protected]",
"invited_first_name": "Alex",
"invited_last_name": "Dev",
"role": "viewer",
"invited_by_user_id": "owner-uuid",
"environment_id": "env-uuid",
"group_id": "group-uuid",
"subgroup_id": "subgroup-uuid",
"accepted": false,
"created_at": "2026-09-10T12:00:00Z",
"accepted_at": null,
"expires_at": "2026-09-11T12:00:00Z"
}Invitations expire 24 hours after creation. The email is enqueued as a background job; a failure to enqueue does not fail the request.
Creating an invitation does not touch the invited account. Inviting an address used to clear that account’s ability to create projects — one request, no redemption and no consent, available to any project owner against any address they could guess. Nothing about a pre-existing account is written by this endpoint now.
| Status | Description |
|---|---|
403 | Project owner required |
404 | Project not found |
422 | Validation error (invalid email, malformed uuid) |
List Invitations
GET /invites?project_id={id}
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://app.quazzar.space/invites?project_id=PROJECT_UUID"Returns a plain JSON array of InviteOut objects, newest first. Requires the project owner role. No token field, on this endpoint or any other.
Resend an Invitation
POST /invites/{invite_id}/resend
curl -X POST https://app.quazzar.space/invites/INVITE_UUID/resend \
-H "Authorization: Bearer YOUR_TOKEN"Mints a new token, resets accepted to false, extends the expiry by 24 hours from now, and sends a fresh email. The previous link stops working. The response is an InviteOut — again without the token.
Refusals follow the tenancy-then-permission rule: 404 Invitation not found if the invitation does not exist or the caller is not a member of its project, then 403 Project owner required if they are a member but not the owner.
Revoke an Invitation
DELETE /invites/{invite_id}
curl -X DELETE https://app.quazzar.space/invites/INVITE_UUID \
-H "Authorization: Bearer YOUR_TOKEN"The row is not deleted — expires_at is set to now, so the link is invalid immediately while the record stays auditable.
| Status | Description |
|---|---|
400 | Invitation already accepted |
403 | Project owner required (caller is in the project but not its owner) |
404 | Invitation not found — no such invitation, or not in a project the caller belongs to |
Accepting when you are already signed in
POST /invites/{invite_id}/accept
curl -X POST https://app.quazzar.space/invites/INVITE_UUID/accept \
-H "Authorization: Bearer YOUR_TOKEN"Response: 204 No Content. The signed-in account’s email must equal the invited address.
A mismatched address answers 404 Invitation not found, not 403. No membership exists yet — accepting is what creates it — so the identity check is this endpoint’s tenancy check and answers the same 404 as “no such invitation”. It also runs first: the already accepted and expired refusals used to stand in front of it, so a stranger walking uuids learned both that an invitation was real and what had become of it.
| Status | Description |
|---|---|
400 | Invitation already accepted or Invitation expired |
404 | Invitation not found — no such invitation, or the signed-in address is not the invited one |
Note that this endpoint needs the invitation id, and the invitation email carries the token. In practice an invitee arrives with a token, which is what the next endpoint is for.
Accepting with a token
POST /invites/accept/by-token
This endpoint is unauthenticated. What it does depends on whether the invited address already has an account.
No account yet — the case this endpoint exists for:
curl -X POST https://app.quazzar.space/invites/accept/by-token \
-H "Content-Type: application/json" \
-d '{
"token": "invitation-token-from-email",
"password": "chosen-password",
"name": "Alex Dev"
}'An account is created with the given password, linked to the project, and a session is issued.
Response (200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"environment_id": "env-uuid",
"group_id": "group-uuid",
"subgroup_id": "subgroup-uuid",
"accepted": true,
"created_at": "2026-09-10T12:00:00Z",
"accepted_at": "2026-09-10T12:30:00Z",
"token": "invitation-token-from-email",
"expires_at": "2026-09-11T12:00:00Z"
}An existing active account gets 409
If the invited address already has an account that has been signed in to, an unauthenticated call answers 409 Conflict and no session is created:
{
"detail": "An account already exists for this address. Sign in as that account and open the invitation link again, or reset your password if you cannot sign in."
}The password field is not read and not written in this case. Until this check, the endpoint would overwrite an existing account’s password hash with the caller’s chosen value and return a signed access token minted for that account — so anyone able to create a project could take over any account knowing only its email address, and the victim’s own password stopped working. Sign in as the account and call the endpoint again with that session, or use POST /auth/password/reset, which proves mailbox control through the route built for it.
To accept as an existing account, send the same request while authenticated as the invited address. Being signed in as somebody else does not qualify — the authenticated address must equal the invited one, or the 409 still applies.
The one exception: an account nobody has ever signed in to
An account whose last-login timestamp is null and which has no authenticator enrolled may still set its password through this endpoint, unauthenticated. These accounts exist — an earlier invitation or an administrator created one — and their owner has otherwise no way in, having never chosen a password.
Two boundaries on it:
- An account with any second factor is excluded, even if it has never signed in. Password reset refuses to proceed for such an account without a valid code, so allowing it here would be a second-factor bypass through the invitation flow.
- Accepting closes the carve-out behind itself. This request records a login, so a second invitation to the same address cannot set its password again.
400 is returned if password is omitted in either password-setting case (A password is required to claim this invitation, or ...to create an account for this invitation).
Refusals
| Status | Description |
|---|---|
400 | Invitation already accepted, Invitation expired, or a required password was omitted |
404 | Invitation not found — the token does not match any invitation |
409 | The address already has an active account; sign in and retry |
An unknown token answers 404 regardless of whether an account exists for any address, so this endpoint is not an account-existence oracle. Reaching the 409 requires a valid, unaccepted, unexpired token, which is issued for one address that its holder therefore already knows.
Account classes
Every account is exactly one of three classes, held in the account_type column:
| Class | Meaning |
|---|---|
customer | Owns projects and carries the subscription |
project_user | Works inside somebody else’s project and owns none |
staff | Quazzar Space staff. A separate axis from platform privilege (is_superuser) |
can_create_projects is derived from account_type, not stored. It is exactly account_type == "customer", computed on read. The physical users.can_create_projects column is inert: nothing reads it, and no code path writes the class through that name. It is not a capability toggle you can set — it is a restatement of the class.
Name collision. The
project_useraccount class is not the same thing as aProjectUserrow (theproject_userstable, served under/project-users), which is a project-local person record that may have no login at all. A search for either finds the other.
Accepting an invitation can change your class
Both acceptance routes apply the same rule. They used to disagree — one classified and the other did not, so the same person accepting the same invitation was reclassified or not depending on which endpoint they reached.
An account is moved from customer to project_user on acceptance unless either of these holds:
- It owns a project — it has an owner membership on any project. Owning your own project and being a member of somebody else’s are orthogonal: a customer invited into a colleague’s project does not stop being the owner and payer of their own.
- It has an active subscription — status exactly
active. A subscription belongs to an account, not to a project, andPOST /subscriptions/choosesucceeds for an account with no projects at all, so somebody can be paying before their first project exists. Cancelled and pending subscriptions do not count.
A project_user is already in the target class and is left alone. staff is never moved — it is the third class, orthogonal to this axis, and an administrator accepting an invitation is still an administrator.
An account created by POST /invites/accept/by-token is a project_user from its first row: it owns nothing.
This matters because POST /projects refuses a project_user with 403 You are not allowed to create projects, and only an administrator can change an account’s class back.
Next Steps
- Errors & Permissions — the
404-before-403rule in full - Projects API — project and environment roles
- Authentication — sessions and password reset