Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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:

PermissionGates
org:readView the organization, its members, and pending invitations
org:manageUpdate organization settings; delete the organization
org:members:writeChange member roles and remove members
org:invitations:writeSend and revoke organization invitations

Default role → permission mapping:

RolePermissions
adminorg:read, org:manage, org:members:write, org:invitations:write
memberorg:read

Response codes for authorization

OutcomeCode
Org does not exist OR caller is not a member404
Caller is a member but lacks the required permission403

The 404-for-non-members rule is intentional and prevents cross-tenant probing for organization IDs.

Organizations

MethodEndpointRequired permission
POST/api/organizationsnone (membership-only) — caller becomes first admin
GET/api/organizationsnone — lists caller's own orgs (paginated)
GET/api/organizations/:idorg:read (every member has it)
PUT/api/organizations/:idorg:manage
DELETE/api/organizations/:idorg: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

MethodEndpointRequired permission
GET/api/organizations/:id/membersorg:read
PUT/api/organizations/:id/members/:user_idorg:members:write
DELETE/api/organizations/:id/members/:user_idorg: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.

MethodEndpointRequired permission
GET/api/organizations/:id/rolesorg:read
POST/api/organizations/:id/rolesorg:manage
PUT/api/organizations/:id/roles/:role_idorg:manage
DELETE/api/organizations/:id/roles/:role_idorg: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).

DELETE403 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

MethodEndpointRequired permission
POST/api/organizations/:id/invitationsorg:invitations:write
GET/api/organizations/:id/invitationsorg:read
DELETE/api/organizations/:id/invitations/:invitation_idorg:invitations:write
POST/api/organizations/accept-invitationnone (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 on org: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 typeEmitted on
org_createdSuccessful create
org_updatedSuccessful update
org_deletedSuccessful delete
member_role_changedRole updated
member_removedMember removed
org_invitation_sentInvitation created + emailed
org_invitation_revokedInvitation revoked
org_invitation_acceptedInvitation accepted
org_role_createdCustom role created
org_role_updatedCustom role updated
org_role_deletedCustom role deleted

Active organization

MethodEndpointDescription
POST/api/organizations/:id/activateSwitch 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