Organizations API
The Organizations API manages organizations, their members, and email invitations. All endpoints require an authenticated session (cookie or Authorization: Bearer …) and are scoped to the current Application via X-App-Id / host context.
Authorization model
Mutations are gated by permissions, not role strings. Every organization-scoped route resolves the caller's role through organization_members and walks the per-app catalog (see Organization Roles) to derive the permission set.
The four permissions used by the API are:
| Permission | Gates |
|---|---|
org:read | View the organization, its members, and pending invitations |
org:manage | Update organization settings; delete the organization |
org:members:write | Change member roles and remove members |
org:invitations:write | Send and revoke organization invitations |
Default role → permission mapping:
| Role | Permissions |
|---|---|
admin | org:read, org:manage, org:members:write, org:invitations:write |
member | org:read |
Response codes for authorization
| Outcome | Code |
|---|---|
| Org does not exist OR caller is not a member | 404 |
| Caller is a member but lacks the required permission | 403 |
The 404-for-non-members rule is intentional and prevents cross-tenant probing for organization IDs.
Organizations
| Method | Endpoint | Required permission |
|---|---|---|
POST | /api/organizations | none (membership-only) — caller becomes first admin |
GET | /api/organizations | none — lists caller's own orgs (paginated) |
GET | /api/organizations/:id | org:read (every member has it) |
PUT | /api/organizations/:id | org:manage |
DELETE | /api/organizations/:id | org:manage |
Slugs are unique per application, 3–63 chars, ^[a-z](?:[a-z0-9-]*[a-z0-9])?$, no consecutive hyphens.
GET /api/organizations/:id returns the caller's role and the permission set their role grants, so the dashboard can render permission-gated UI without an extra round-trip:
{
"organization": { "id": "…", "name": "…", "slug": "…", … },
"caller": {
"user_id": "…",
"role": "admin",
"permissions": ["org:read", "org:manage", "org:members:write", "org:invitations:write"]
},
"member_count": 7
}Members
| Method | Endpoint | Required permission |
|---|---|---|
GET | /api/organizations/:id/members | org:read |
PUT | /api/organizations/:id/members/:user_id | org:members:write |
DELETE | /api/organizations/:id/members/:user_id | org:members:write |
Role-change and remove endpoints return 409 when the action would leave the organization with zero members holding org:manage. The guard lives in the SQL WHERE clause, so two concurrent self-demotes cannot both succeed. See Organization Roles → Last-org:manage guard.
Custom roles
Custom roles are managed through /api/organizations/:id/roles. The :id segment is a permission-gating context (the caller must hold org:manage in that org), but the role definitions themselves are app-scoped — once created, a role is available in every organization of the application.
| Method | Endpoint | Required permission |
|---|---|---|
GET | /api/organizations/:id/roles | org:read |
POST | /api/organizations/:id/roles | org:manage |
PUT | /api/organizations/:id/roles/:role_id | org:manage |
DELETE | /api/organizations/:id/roles/:role_id | org:manage |
GET /api/organizations/:id/roles returns both the role list (presets + custom) and the permission catalog:
{
"roles": [
{
"id": "orgrole:app_abc:admin",
"name": "admin",
"description": "…",
"is_system": true,
"permissions": ["org:read", "org:manage", "org:members:write", "org:invitations:write"]
},
{
"id": "<uuid>",
"name": "billing",
"description": "Read-only finance",
"is_system": false,
"permissions": ["org:read"]
}
],
"permissions": [
{ "id": "orgperm:app_abc:org:read", "name": "org:read", "description": "View the organization, its members, and pending invitations" }
]
}POST body — { name, description?, permissions: string[] }. Returns 201. Errors: 400 (unknown permission name), 403 (caller missing org:manage), 409 (name in use, including admin / member).
PUT body — { name?, description?, permissions? }, at least one field required. Setting permissions replaces the set wholesale. Errors: 400 (unknown permission), 403 (preset / is_system = 1), 404 (not found), 409 (name collision).
DELETE — 403 for presets, 404 for not-found, 409 when one or more members still hold the role (body includes in_use_count).
Audit events: org_role_created, org_role_updated, org_role_deleted.
Invitations
| Method | Endpoint | Required permission |
|---|---|---|
POST | /api/organizations/:id/invitations | org:invitations:write |
GET | /api/organizations/:id/invitations | org:read |
DELETE | /api/organizations/:id/invitations/:invitation_id | org:invitations:write |
POST | /api/organizations/accept-invitation | none (auth required) |
The list endpoint is visible to any member (org
), not admins only — a deliberate change from the pre-RBAC behavior. Invite/revoke remain gated onorg:invitations:write.
Invitation tokens are 64 hex characters. The accept endpoint requires the caller's email to match the invitation's email (case-insensitive). Email link target: {appPublicBaseUrl}/accept-org-invite?token=....
Audit events
| Event type | Emitted on |
|---|---|
org_created | Successful create |
org_updated | Successful update |
org_deleted | Successful delete |
member_role_changed | Role updated |
member_removed | Member removed |
org_invitation_sent | Invitation created + emailed |
org_invitation_revoked | Invitation revoked |
org_invitation_accepted | Invitation accepted |
org_role_created | Custom role created |
org_role_updated | Custom role updated |
org_role_deleted | Custom role deleted |
Active organization
| Method | Endpoint | Description |
|---|---|---|
POST | /api/organizations/:id/activate | Switch the caller's active organization. Membership-only; re-mints the session JWT with an active_org_id claim and revokes the previous session row. Returns { ok: true, active_org_id, role }. |
Returns 404 to non-members and for organizations in other applications (deliberate — avoids cross-tenant probing). The active_org_id claim is a hint surfaced through GET /api/users/me; per-request authorization always re-checks organization_members, so a forged or stale claim never grants access.
/api/users/me echo
GET /api/users/me re-validates the JWT's active_org_id claim against organization_members on every call. The response always includes the active-org trio plus an org_count field (used by the org switcher); if the user is still a member, the trio is populated:
{
"active_org_id": "org_abc…",
"active_org_role": "admin",
"active_org_permissions": ["org:read", "org:manage", …],
"org_count": 3
}If membership has been revoked, the three active_org_* fields are null in the response (the claim is silently dropped — the JWT itself is not rewritten). org_count is unaffected. This lets the dashboard treat the trio as a source of truth without re-fetching the org just to know whether the claim is still valid.
Related
- Organizations — Overview and concepts
- Membership & Invitations — Membership and invitation flows
- Organization Roles — Permission catalog, role presets, and the last-
org:manageguard