Organizations
Self-service organization (workspace) and membership management. An organization owns one or more applications (tenants); users belong to organizations with a role, and can be members of several organizations at once.
Authentication
All endpoints require a Bearer access token (Authorization: Bearer <accessToken>) plus the X-Bulwark-Tenant header. Authorization is role-based within the active organization.
Roles
| Role | Can |
|------|-----|
| viewer | Read org state, members, and invites |
| member | (reserved; same as viewer today) |
| admin | Invite members, revoke invites, remove members |
| owner | Everything, plus change member roles |
The active organization is taken from the access token. Use Switch Organization to change it.
Last-owner guard: an organization must always have at least one owner. Demoting or removing the last owner fails with
400— transfer ownership first.
Current Organization
GET /api/v1/organizations/current
Requires viewer.
Response 200
{
"organization": {
"id": "org_01j...",
"name": "Acme",
"slug": "acme",
"display_name": "Acme",
"status": "active",
"settings": {},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
My Organizations
GET /api/v1/me/organizations
Lists every organization the caller is an accepted member of. The active flag marks the one the current access token is scoped to. Any authenticated user.
Response 200
{
"organizations": [
{
"id": "org_01j...",
"name": "Acme",
"slug": "acme",
"display_name": "Acme",
"role": "owner",
"active": true
}
]
}
Switch Organization
POST /api/v1/me/switch
Re-issues the access token scoped to a different organization the caller belongs to. The refresh token is unchanged (it's tied to the user, not the org). Any authenticated user who is an accepted member of the target org.
Body
{
"organization_id": "org_01j..."
}
Response 200
{
"access_token": "eyJ...",
"organization_id": "org_01j...",
"role": "admin"
}
Returns 403 if the caller is not an accepted member of the target organization.
List Members
GET /api/v1/organizations/current/members
Requires viewer. Members may be users or agents; email / display_name are populated for users and null for agents.
Response 200
{
"members": [
{
"id": "mem_01j...",
"organization_id": "org_01j...",
"subject_type": "user",
"subject_id": "usr_01j...",
"role": "owner",
"invited_by": null,
"invited_at": null,
"accepted_at": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"email": "[email protected]",
"display_name": "Ada Lovelace"
}
]
}
Update Member Role
PUT /api/v1/organizations/current/members/{id}/role
Requires owner. {id} is the member's id from the members list.
Body
{
"role": "admin"
}
Valid roles: owner, admin, member, viewer.
Response 200
{ "status": "updated" }
Returns 400 if this would demote the last owner.
Remove Member
DELETE /api/v1/organizations/current/members/{id}
Requires admin.
Response 200
{ "status": "removed" }
Returns 400 if this would remove the last owner.
List Invites
GET /api/v1/organizations/current/invites
Requires viewer. The invite token is never returned (only its hash is stored).
Response 200
{
"invites": [
{
"id": "inv_01j...",
"email": "[email protected]",
"role": "member",
"status": "pending",
"expires_at": "2026-06-08T00:00:00Z",
"created_at": "2026-06-01T00:00:00Z",
"invited_by_user_id": "usr_01j..."
}
]
}
status is one of pending, accepted, revoked, expired.
Create Invite
POST /api/v1/organizations/current/invites
Requires admin. Generates a one-time token (valid 7 days), emails it to the invitee, and returns the invite metadata. The raw token is not in the response — it's delivered by email only.
Body
{
"email": "[email protected]",
"role": "member"
}
role may be admin, member, or viewer — never owner (ownership is transferred, not invited). You cannot invite your own email.
Response 201
{
"invite": {
"id": "inv_01j...",
"email": "[email protected]",
"role": "member",
"status": "pending",
"expires_at": "2026-06-08T00:00:00Z"
}
}
Errors
400— invalid role, inviting yourself, or inviting asowner.409— a pending invite already exists for that email in this organization.
Revoke Invite
POST /api/v1/organizations/current/invites/{id}/revoke
Requires admin. Idempotent — a no-op if the invite is already accepted, revoked, or expired.
Response 200
{ "status": "revoked" }
Accept Invite
POST /api/v1/organizations/invites/accept
Any authenticated user. Accepts an invite using the raw token from the invitation email and adds the caller to the organization with the invited role.
Body
{
"token": "<raw-token-from-the-invite-email>"
}
Response 200
{
"membership": {
"id": "mem_01j...",
"organization_id": "org_01j...",
"subject_type": "user",
"subject_id": "usr_01j...",
"role": "member",
"invited_at": "2026-06-01T00:00:00Z",
"accepted_at": "2026-06-01T00:00:00Z",
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
}
}
Errors
400— missing token.404— invite not found.410— invite expired, revoked, or already accepted.