Invitations
Klaxon supports two invite shapes that share a single invitations table and handler. Both are admin-initiated and both land at /invite/<code> in the web UI; the difference is how they're constrained.
| Shape | Targeted at | max_uses | Email lock |
|---|---|---|---|
| Email invite | one address you type in | forced to 1 | yes — only that email can accept |
| Link invite | nobody specific | null (unlimited) or an integer | no — anyone with the URL can accept |
Both shapes carry a pre-assigned role (and optionally a team). The invitee signs in with any IdP, then accepts.
Create an invitation
# Email invite
curl -X POST https://api.klaxon.sh/api/admin/invitations \
-H "Authorization: Bearer $TOKEN" \
-d '{
"email":"new@example.com",
"role":"member",
"team_id":"optional-team-uuid"
}'
# Link invite (max 5 uses over 7 days)
curl -X POST https://api.klaxon.sh/api/admin/invitations \
-H "Authorization: Bearer $TOKEN" \
-d '{
"role":"member",
"max_uses":5,
"expires_in_hours":168
}'The response includes invite_code (32 chars of URL-safe base64, ~192 bits of entropy) and invite_url (fully-qualified, ready to share).
Permission required: users.invite. Only an Owner may invite someone as Owner. The caller's role must include every permission they're granting — i.e. you can't invite a custom role with org.delete unless you yourself have it.
Look up an invitation (public)
curl https://api.klaxon.sh/auth/invitations/$CODENo auth required. Returns an InvitationPreview with org_name, role, team_name (if any), inviter_name, and email if the invite is email-locked. Missing / revoked / expired / fully-used invites all return 404 — the server doesn't leak which it is.
Accept an invitation
curl -X POST https://api.klaxon.sh/auth/invitations/accept \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"invite_code":"…"}'The caller must be authenticated. On success:
- Creates an
org_membershipsrow with the invited role. - If the invite carried a
team_idand that team still exists, creates ateam_membershipsrow. - Bumps
use_count; email invites (max_uses = 1) are now exhausted. - Mints a fresh 7-day session token scoped to the newly joined org and returns it.
Return shape:
{ "org_id": "…", "user_id": "…", "role": "member", "token": "…" }The UI uses the returned token directly so the user lands inside the new workspace without a switch-org round trip.
Error responses
| Status | Error | When |
|---|---|---|
403 | invitation is for a different email | Email-lock mismatch |
410 | invitation revoked | Admin hit revoke |
410 | invitation expired | expires_at <= now() |
410 | invitation fully used | use_count >= max_uses |
402 | plan_limit_exceeded (key max_seats) | Accept would exceed the plan's seat count |
404 | invitation not found | Bad code (or any of the above surfaced via preview) |
The plan gate fires inside the same transaction that locks the invitation row, so two parallel accepts can't both sneak past the seat limit.
Revoke an invitation
curl -X POST https://api.klaxon.sh/api/admin/invitations/$INVITE_ID/revoke \
-H "Authorization: Bearer $TOKEN"Sets revoked_at to now(). Subsequent previews 404; accepts return 410.
Email delivery (Resend)
Email invitations are sent via Resend.
| Env var | Required | Purpose |
|---|---|---|
RESEND_API_KEY | yes, for email delivery | Resend API key |
KLAXON_INVITE_FROM_ADDRESS | default invites@klaxon.sh | From: address |
If RESEND_API_KEY is unset or the API call fails, the request still succeeds — the invite row is created and the admin can recover the URL from the POST response or via GET /api/admin/invitations. A tracing::error! logs the send failure so you'll see it in the collector.
The From address must have its domain verified in the Resend dashboard.
List open invitations
curl https://api.klaxon.sh/api/admin/invitations \
-H "Authorization: Bearer $TOKEN"Returns active invitations only — revoked, expired, and exhausted rows are filtered out. Use this for the admin-panel "Pending invitations" view.
Web UI flow
- Admin clicks Invite in the admin panel, fills the form (email or link, role, optional team, optional expiry).
- Server creates the row and — if email — sends the Resend mail.
- Invitee clicks the link → lands at
/invite/<code>→ sees the preview, signs in via their preferred IdP (or magic link). POST /auth/invitations/acceptruns automatically once authenticated.- The UI swaps to the new token and navigates into the joined org.
Related guides
- Admin Panel — full admin surface
- Roles & Permissions —
users.inviteand the Owner guard - Teams — invites can carry
team_id - Billing —
max_seatsgating on accept