Workspaces
A workspace is a sub-unit inside an organization — the day-to-day container that scopes membership, app access, spaces ("locations"), and channels for a specific team or venue. An org typically holds several workspaces (e.g. one per cinema branch); a workspace never spans organizations.
Handles:
- Workspace CRUD — create, list, get, update, delete
- Workspace app access — which apps this workspace's members can use (separate from the org-level grant)
- Workspace membership — add/remove members, change their workspace role
- Space and channel assignment — attach existing locations/channels to a workspace
Every org gets a Default workspace auto-created the moment it has an admin user — see Default Workspace Creation below. Workspace access can also be granted to a whole group of users at once via Teams, which layers on top of (and is mutually exclusive with) the direct membership described here.
Architecture
graph LR
AdminPortal["Org / Workspace Admin"]
CoreAPI["CoreAPI (Tenancy — workspaces)"]
CoreDB["CoreAPI DB\n(workspaces, workspace_members,\nworkspace_app_access, workspace_locations,\nworkspace_channels)"]
AdminPortal -->|POST/PATCH/DELETE workspaces| CoreAPI
AdminPortal -->|members, app-access, locations, channels| CoreAPI
CoreAPI --> CoreDB
Workspaces are entirely CoreAPI-local — unlike organization creation, there's no AuthAPI round-trip. Workspace membership is a separate concept from org membership: a user must already be an org member to be added to one of its workspaces, but org membership alone doesn't grant workspace access.
Data Model
erDiagram
Workspace {
string id
string organizationId
string name
string workspaceSlug
string description
string workspaceType
string createdBy
}
WorkspaceMember {
string workspaceId
string userId
string role
}
WorkspaceAppAccess {
string workspaceId
string appId
}
WorkspaceLocation {
string workspaceId
string locationId
}
WorkspaceChannel {
string workspaceId
string channelId
}
Organization ||--o{ Workspace : contains
Workspace ||--o{ WorkspaceMember : has
Workspace ||--o{ WorkspaceAppAccess : "app access"
Workspace ||--o{ WorkspaceLocation : "spaces"
Workspace ||--o{ WorkspaceChannel : channels
Workspace roles are WORKSPACE_ADMIN > WORKSPACE_MEMBER > VIEWER (in that priority order). Deleting a workspace cascades across all five tables above plus workspace_settings, in a single transaction.
Default Workspace Creation
Every org gets one workspace created automatically, named literally "Default" (slug default, type Custom), with whatever apps the org currently has access to and the org's new admin user as its WORKSPACE_ADMIN. This isn't an explicit API call — it's a side effect fired from two places:
POST /tenancy/organizations(direct, non-self-signup creation) — right after the admin user is created, forcloudorgs only.on_premorgs never get one (they have no local admin user or app access to seed it with).PUT /tenancy/organizations/:orgId/statusapproving a pending self-signup org — fires only on that pending→active transition, using the org's app access as granted at approval time (which may differ from what was requested at self-signup, if it changed in between).
There is no flag or endpoint to skip it. Creation is best-effort: it goes through the same validation as a manual POST .../workspaces call, so it can fail (e.g. if somehow no app access exists yet) — a failure is logged server-side but does not fail the surrounding org-creation/approval request.
Endpoints
All routes below are mounted under /api/v1/tenancy/organizations/:orgId/workspaces and require a valid session (ProtectedRS256). Endpoints that mutate state additionally require the WORKSPACE_ADMIN role in the target workspace, via RequireWorkspaceRole — with one exception: an ORG_OWNER or ORG_ADMIN of the workspace's parent org always passes this check, regardless of their own workspace membership.
GET /
Lists every workspace in the org, newest first, each with its app IDs, member count, space count, and channel count.
Response 200
[
{
"id": "ws_...", "name": "KLCC Branch", "workspaceSlug": "klcc-branch",
"description": "", "workspaceType": "Custom",
"appIds": ["ticketing"], "memberCount": 4, "spaceCount": 2, "channelCount": 1
}
]
POST / — requires org permission org:workspaces:create
Creates a workspace and, in one transaction, grants its app access and adds an initial WORKSPACE_ADMIN member.
nameand at least oneappIdsentry are required.workspaceSlugis optional — if omitted, it's derived fromname(lowercased, non-alphanumerics collapsed to single dashes). An explicit slug is still normalized the same way. Slugs and names (case-insensitive) must each be unique within the org — a collision returns409.workspaceTypedefaults to"Custom".adminUserIdis optional and defaults to the caller. If set explicitly, that user must already be a member of the org (400otherwise). The caller themself must also be an org member (403if not) and must have a localusersrow (401if not).
Request
{
"name": "KLCC Branch",
"description": "Flagship branch",
"workspaceType": "Cinema",
"appIds": ["ticketing", "signage"],
"adminUserId": "usr_..."
}
Response 201
{ "id": "ws_...", "name": "KLCC Branch", "workspaceSlug": "klcc-branch", "appIds": ["ticketing", "signage"], "adminUserId": "usr_..." }
GET /:workspaceId
Fetches a single workspace by UUID, scoped to orgId. 404 if it doesn't belong to that org.
PATCH /:workspaceId — requires WORKSPACE_ADMIN
Updates name, description, and/or workspaceType; any field left empty is left unchanged. At least one field must be non-empty. A new name is checked for uniqueness within the org (excluding this workspace) and returns 409 on collision.
Request
{ "description": "Flagship branch — renovated 2026" }
PUT /:workspaceId/app-access — requires WORKSPACE_ADMIN
Replaces the workspace's full app access list. At least one app is required; unknown app IDs return 400.
Request
{ "appIds": ["ticketing", "signage", "menu-board"] }
DELETE /:workspaceId — requires WORKSPACE_ADMIN
Deletes the workspace and all related rows (members, app access, channels, spaces, settings). Returns 204 with no body.
GET /:workspaceId/members
Paginated member list (page, limit query params, default 1/20, max limit 100), ordered by join time.
Response 200
{
"items": [ { "userId": "usr_...", "name": "Ada Lovelace", "email": "ada@acme.example.com", "role": "WORKSPACE_ADMIN" } ],
"total": 1, "page": 1, "limit": 20, "hasNext": false
}
POST /:workspaceId/members — requires WORKSPACE_ADMIN
Adds a user to the workspace. role defaults to WORKSPACE_MEMBER if omitted. A no-op if the user is already a member.
Request
{ "userId": "usr_...", "role": "VIEWER" }
409 "user already has access to this workspace through a team" if the user already reaches this workspace via a team grant — direct and team-based access are mutually exclusive.
PATCH /:workspaceId/members/:userId — requires WORKSPACE_ADMIN
Changes a member's workspace role. Callers cannot change their own role (403).
Request
{ "newRole": "WORKSPACE_ADMIN" }
DELETE /:workspaceId/members/:userId — requires WORKSPACE_ADMIN
Removes a member from the workspace. Callers cannot remove themselves (403). 404 if the target wasn't a member.
POST /:workspaceId/locations — requires WORKSPACE_ADMIN
Assigns an existing location ("space") to the workspace. A no-op if already assigned.
Request
{ "locationId": "loc_..." }
POST /:workspaceId/channels — requires WORKSPACE_ADMIN
Assigns an existing channel to the workspace. A no-op if already assigned.
Request
{ "channelId": "chn_..." }
GET /:workspaceId/teams
Lists the teams that have been granted a role on this workspace, alongside the direct member list above — see Teams: Interaction with Direct Workspace Membership for how the two combine.