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 as owner.
  • 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.