Workspaces API
Workspace management: CRUD, members, invitations, teams, and settings. Workspaces are the top-level tenant — every page, database, file, comment, webhook, and API key belongs to exactly one workspace. See authentication.md for the shared conventions (base URL, auth, error model, pagination) used throughout this document.
- Auth: JWT or API key, except where noted. Workspace routes require the caller to be a member of that workspace (role-checked per operation).
- Workspace resolution: path parameter
:workspaceId; for unscoped routes use theX-Factory-Workspaceheader. - Roles (workspace level):
OWNER>ADMIN>MEMBER>GUEST. Role requirements are stated per endpoint.
1. Workspaces
1.1 GET /workspaces
List the workspaces the caller belongs to (JWT). For API keys this endpoint returns only the key's home workspace.
- Auth: JWT (any role in the workspace) / API key (any scope).
Query params: cursor, limit (default 20, max 100).
200 OK
{
"data": [
{
"id": "wks_01J9ZKP2B7F4H6J8K0L2M4N6P8",
"name": "Ada Lovelace's Workspace",
"slug": "ada-lovelace-7f3k9q",
"iconUrl": null,
"logoUrl": null,
"description": null,
"publicUrl": null,
"isPersonal": true,
"ownerId": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"billingPlan": "free",
"role": "OWNER",
"createdAt": "2024-11-03T09:00:00.000Z",
"updatedAt": "2025-01-02T08:12:00.000Z"
},
{
"id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"name": "Analytical Engines Co.",
"slug": "analytical-engines",
"iconUrl": "https://factory.example.com/files/fil_01J9ZKP…/logo.png",
"description": "Product, engineering and ops workspace.",
"isPersonal": false,
"ownerId": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"billingPlan": "team",
"role": "ADMIN",
"createdAt": "2024-10-15T09:00:00.000Z",
"updatedAt": "2025-01-01T17:40:00.000Z"
}
],
"nextCursor": null
}
Errors: 401 UNAUTHORIZED.
1.2 POST /workspaces
Create a shared (non-personal) workspace. The creator becomes OWNER.
Rate limit: 10 / day / user.
- Auth: JWT (only — API keys cannot create workspaces;
403 FORBIDDENwithdetails: [{reason: "requires_user_credentials"}]).
Request:
{
"name": "Analytical Engines Co.",
"slug": "analytical-engines",
"description": "Product, engineering and ops workspace.",
"iconUrl": "https://factory.example.com/files/fil_01J9ZKP…/logo.png"
}
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string (1–100) | yes | Display name. |
slug |
string (1–50, [a-z0-9-]) |
no | Unique subdomain; generated from name when omitted. |
description |
string (≤ 500) | no | |
iconUrl |
string (url) | no | Upload the icon via the files API first. |
201 Created
{
"data": {
"id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"name": "Analytical Engines Co.",
"slug": "analytical-engines",
"isPersonal": false,
"ownerId": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"settings": { "language": "en-US", "theme": "system", "shareLinks": { "allowed": true } },
"billingPlan": "free",
"usageLimits": { "members": 5, "storageBytes": 1073741824, "databases": 25 },
"createdAt": "2025-01-02T10:00:00.000Z"
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Bad name/slug. |
| 403 | FORBIDDEN |
API key used; workspace quota reached. |
| 409 | CONFLICT |
Slug taken. |
Events emitted: workspace.created, workspace.member_added (creator, role OWNER).
1.3 GET /workspaces/:workspaceId
- Auth: JWT (any role) / API key (any scope).
200 OK
{
"data": {
"id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"name": "Analytical Engines Co.",
"slug": "analytical-engines",
"iconUrl": "https://factory.example.com/files/fil_01J9ZKP…/logo.png",
"logoUrl": "https://factory.example.com/files/fil_01J9ZKP…/logo.png",
"description": "Product, engineering and ops workspace.",
"publicUrl": "https://analytical-engines.factory.app",
"isPersonal": false,
"ownerId": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"settings": {
"language": "en-US",
"theme": "system",
"shareLinks": { "allowed": true },
"trashRetentionDays": 30,
"auditLogRetentionDays": 365,
"sessionTimeoutDays": 30
},
"billingPlan": "team",
"usageLimits": { "members": 15, "storageBytes": 10737418240, "databases": 100 },
"usage": { "members": 6, "storageBytes": 838860800, "databases": 12 },
"role": "ADMIN",
"createdAt": "2024-10-15T09:00:00.000Z",
"updatedAt": "2025-01-01T17:40:00.000Z"
}
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN (not a member), 404 NOT_FOUND.
1.4 PATCH /workspaces/:workspaceId
Update workspace metadata. Merge-patch: only provided fields change.
- Auth: JWT (Admin or Owner). API key:
ADMINscope required.
Request (any subset):
{
"name": "Analytical Engines",
"description": "The product & engineering home.",
"iconUrl": "https://factory.example.com/files/fil_01J9ZKP…/logo-v2.png"
}
slug is immutable (it is the subdomain) — include it and you get
400 VALIDATION_ERROR with details: [{field: "slug", issue: "immutable"}].
200 OK — returns the updated workspace (same shape as GET).
Errors: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN,
404 NOT_FOUND, 409 CONFLICT (slug collision if you explicitly changed it).
Events emitted: workspace.updated.
1.5 DELETE /workspaces/:workspaceId
Delete a workspace and all of its content (pages, databases, files, comments, webhooks). Personal workspaces cannot be deleted (dissolve the account instead).
- Auth: JWT (Owner only). API keys: never allowed (
403 FORBIDDEN). - Destructive: requires
X-Confirm: DELETE <workspaceId>header.
202 Accepted — deletion runs as a background job; the workspace enters
deletedAt state immediately (all endpoints then return 410 GONE) and is
hard-removed after the retention window (default 30 days).
{ "data": { "status": "deleting", "scheduledPurgeAt": "2025-02-01T10:00:00.000Z" } }
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing/wrong X-Confirm header. |
| 403 | FORBIDDEN |
Not the owner; API key. |
| 404 | NOT_FOUND |
No such workspace. |
| 409 | CONFLICT |
Personal workspace (details: [{reason: "personal_workspace"}]). |
Events emitted: workspace.deleted (audit, all member ids recorded).
2. Settings
2.1 PATCH /workspaces/:workspaceId/settings
Update the workspace settings object (merge-patch, one level deep).
- Auth: JWT (Admin or Owner). API key:
ADMINscope.
Request (any subset):
{
"language": "en-GB",
"theme": "dark",
"shareLinks": { "allowed": false },
"trashRetentionDays": 14,
"auditLogRetentionDays": 730
}
| Field | Type | Notes |
|---|---|---|
language |
string (BCP-47) | UI default for new members. |
theme |
light | dark | system |
|
shareLinks.allowed |
boolean | Whether members may create public share links. |
trashRetentionDays |
int (1–365) | Days before trashed pages are purged (default 30). |
auditLogRetentionDays |
int (30–3650) | Enterprise. |
sessionTimeoutDays |
int (1–90) | Refresh-token inactivity window. |
200 OK
{
"data": {
"id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"settings": {
"language": "en-GB",
"theme": "dark",
"shareLinks": { "allowed": false },
"trashRetentionDays": 14,
"auditLogRetentionDays": 365,
"sessionTimeoutDays": 30
}
}
}
Errors: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN,
404 NOT_FOUND.
Events emitted: workspace.settings_updated, permission.changed
(audit) when a setting changes who can act.
3. Members
A membership is the row workspace_members (workspaceId × userId × role).
Listing a member's team memberships is available via the ?include=teams
query param.
3.1 GET /workspaces/:workspaceId/members
- Auth: JWT (any role) / API key (any scope).
Query params: cursor, limit (default 50, max 100),
role (filter: OWNER|ADMIN|MEMBER|GUEST), q (case-insensitive match on
name/email), include=teams (adds teams: [{id, name, role}] per member).
200 OK
{
"data": [
{
"id": "wm_01J9ZKP9D2H8J0L2M4N6P8Q0R2",
"userId": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"user": {
"id": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"name": "Charles Babbage",
"displayName": "Charles",
"email": "charles@example.com",
"avatarUrl": null,
"status": "ACTIVE"
},
"role": "OWNER",
"invitedBy": null,
"joinedAt": "2024-10-15T09:00:00.000Z",
"createdAt": "2024-10-15T09:00:00.000Z"
},
{
"id": "wm_01J9ZKP9E4H0J2L4M6N8P0Q2R4",
"userId": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"user": {
"id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"name": "Ada Lovelace",
"displayName": "Ada",
"email": "ada@example.com",
"avatarUrl": null,
"status": "ACTIVE"
},
"role": "ADMIN",
"invitedBy": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"joinedAt": "2024-10-16T14:22:00.000Z",
"createdAt": "2024-10-15T09:05:00.000Z"
}
],
"nextCursor": null
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
3.2 POST /workspaces/:workspaceId/members
Add a member who already has an account. (To invite someone by email who may not have an account, use Invitations instead.)
- Auth: JWT (Admin or Owner; only an Owner may grant
OWNER— transfer of ownership). API key:ADMINscope.
Request:
{
"userId": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0",
"role": "MEMBER"
}
201 Created — returns the membership (same shape as a list item).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Bad role/userId. |
| 403 | FORBIDDEN |
Not allowed to grant that role; member quota reached. |
| 404 | NOT_FOUND |
User or workspace unknown to caller. |
| 409 | CONFLICT |
User is already a member. |
Events emitted: workspace.member_added, notification to the user
(PAGE_SHARED-style workspace.member_added in-app notification).
3.3 PATCH /workspaces/:workspaceId/members/:userId
Change a member's role.
- Auth: JWT (Admin or Owner; an Admin cannot change the Owner's role and
only an Owner can create the second
OWNER). API key:ADMINscope.
Request:
{ "role": "GUEST" }
200 OK — returns the updated membership.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid role. |
| 403 | FORBIDDEN |
Insufficient privilege for the role change. |
| 404 | NOT_FOUND |
User is not a member. |
Events emitted: workspace.member_updated, permission.changed (audit).
3.4 DELETE /workspaces/:workspaceId/members/:userId
Remove a member (their resource-level permissions on this workspace are
revoked; content they created stays, re-parented under the workspace). The
last remaining OWNER cannot be removed.
- Auth: JWT (Admin or Owner; a member may remove themselves = leave).
API key:
ADMINscope.
204 No Content
Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN |
Insufficient privilege (e.g. Admin removing an Owner). |
| 404 | NOT_FOUND |
Not a member. |
| 409 | CONFLICT |
Target is the last owner. |
Events emitted: workspace.member_removed, permission.changed (audit).
4. Invitations
Invitations are email-address-based; accepting one creates the account (or attaches an existing account with that email) and the membership. Invitation tokens are single-use, expire (default 7 days), and are hashed at rest.
4.1 POST /workspaces/:workspaceId/invitations
- Auth: JWT (Admin or Owner). API key:
ADMINscope.
Request:
{
"email": "augusta@example.com",
"role": "MEMBER",
"expiresInDays": 7
}
| Field | Type | Required | Notes |
|---|---|---|---|
email |
string (email) | yes | Invitee address. |
role |
OWNER | ADMIN | MEMBER | GUEST |
no | Default MEMBER. OWNER only by the current owner. |
expiresInDays |
int (1–30) | no | Default 7. |
201 Created — the acceptUrl is the full link sent by email:
{
"data": {
"id": "inv_01J9ZKQ0F6H2J4L6M8N0P2Q4R6S8",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"email": "augusta@example.com",
"role": "MEMBER",
"invitedById": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"acceptUrl": "https://factory.example.com/invite?token=itn_7a2f…",
"expiresAt": "2025-01-09T10:00:00.000Z",
"status": "PENDING",
"createdAt": "2025-01-02T10:00:00.000Z"
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Bad email/role. |
| 403 | FORBIDDEN |
Not allowed to grant role; quota. |
| 404 | NOT_FOUND |
Unknown workspace for caller. |
| 409 | CONFLICT |
An open invitation for the same email+role already exists, or the email is already a member. |
Events emitted: workspace.invitation_sent (email job enqueued; audit).
4.2 GET /workspaces/:workspaceId/invitations
- Auth: JWT (Admin or Owner). API key:
ADMINscope.
Query params: cursor, limit, status (PENDING|ACCEPTED|DECLINED|REVOKED|EXPIRED, default PENDING).
200 OK
{
"data": [
{
"id": "inv_01J9ZKQ0F6H2J4L6M8N0P2Q4R6S8",
"email": "augusta@example.com",
"role": "MEMBER",
"invitedById": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"invitedBy": { "id": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9", "name": "Charles Babbage", "email": "charles@example.com" },
"status": "PENDING",
"expiresAt": "2025-01-09T10:00:00.000Z",
"createdAt": "2025-01-02T10:00:00.000Z"
}
],
"nextCursor": null
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
4.3 POST /invitations/accept
Accept an invitation using the token from the email link. Works anonymously: if no account exists for the email, the request must include credentials to create one.
- Auth: none (the invitation token is the credential) or JWT of the matching account.
Request (new user):
{
"token": "itn_7a2f9c1e4b6d8f0a2c4e6f8b0d1a3c5e",
"password": "s3cure-Passphrase-42",
"name": "Augusta Ada King"
}
Request (existing user, either send JWT or repeat email):
{
"token": "itn_7a2f9c1e4b6d8f0a2c4e6f8b0d1a3c5e",
"email": "augusta@example.com"
}
200 OK
{
"data": {
"user": { "id": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0", "email": "augusta@example.com", "name": "Augusta Ada King" },
"workspace": { "id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", "name": "Analytical Engines Co." },
"membership": { "role": "MEMBER", "joinedAt": "2025-01-02T11:30:00.000Z" },
"accessToken": "eyJhbGciOiJIUzI1NiIs…",
"refreshToken": "rft_…"
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing fields for new-user accept. |
| 403 | FORBIDDEN |
JWT user email ≠ invitation email. |
| 409 | CONFLICT |
Invitation already accepted or revoked. |
| 410 | GONE |
Invitation expired. |
Events emitted: workspace.invitation_accepted, workspace.member_added.
4.4 DELETE /workspaces/:workspaceId/invitations/:invitationId
Revoke a pending invitation (sends no email; the link stops working).
- Auth: JWT (Admin or Owner, or the inviter). API key:
ADMINscope.
204 No Content — idempotent (already terminal invitations also return 204).
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
Events emitted: workspace.invitation_revoked.
5. Teams
Teams (teamspace) group members and can own pages (Page.teamId) and be a
grant scope for permissions and shares. Sidebar ordering uses the fractional
position field.
5.1 GET /workspaces/:workspaceId/teams
- Auth: JWT (any role) / API key (any scope).
Query params: cursor, limit. Ordered by position.
200 OK
{
"data": [
{
"id": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1",
"name": "Engineering",
"slug": "engineering",
"description": "Product engineering.",
"color": "#4F7CFF",
"iconUrl": null,
"position": "a0",
"isPrivate": false,
"memberCount": 4,
"pageCount": 32,
"createdAt": "2024-10-15T10:00:00.000Z",
"updatedAt": "2025-01-01T12:00:00.000Z"
},
{
"id": "tm_01J9ZKP8H7I9J1L3M5N7P9Q1R3",
"name": "Founders Only",
"slug": "founders",
"description": null,
"color": null,
"iconUrl": null,
"position": "a1",
"isPrivate": true,
"memberCount": 2,
"pageCount": 9,
"createdAt": "2024-11-01T10:00:00.000Z",
"updatedAt": "2024-12-20T09:00:00.000Z"
}
],
"nextCursor": null
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
5.2 POST /workspaces/:workspaceId/teams
- Auth: JWT (Admin or Owner;
MEMBERon free+ plans, check plan limits). API key:ADMINscope.
Request:
{
"name": "Engineering",
"slug": "engineering",
"description": "Product engineering.",
"color": "#4F7CFF",
"isPrivate": false,
"position": "a1"
}
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string (1–60) | yes | |
slug |
string (1–50) | no | Unique within workspace. |
description |
string (≤ 500) | no | |
color |
string (#rrggbb) |
no | |
isPrivate |
boolean | no | Default false. Private teams hide their pages from non-members. |
position |
string (fractional) | no | Insert position in the sidebar; default appends after the last team. |
201 Created — returns the team (same shape as list item).
Errors: 400 VALIDATION_ERROR, 403 FORBIDDEN, 404 NOT_FOUND,
409 CONFLICT (slug taken).
Events emitted: team.created.
5.3 GET /workspaces/:workspaceId/teams/:teamId
- Auth: JWT (any role; private teams require membership) / API key.
200 OK — team object (list item shape) plus members preview
(first 10) and rootPages (top-level pages whose teamId matches).
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN (private team, non-member),
404 NOT_FOUND.
5.4 PATCH /workspaces/:workspaceId/teams/:teamId
- Auth: JWT (Admin or Owner, or a team member for non-
isPrivatefields on public teams). API key:ADMINscope.
Request (any subset): { "name": "Platform Eng", "position": "a0V" }
200 OK — updated team.
Errors: 400 VALIDATION_ERROR, 403 FORBIDDEN, 404 NOT_FOUND,
409 CONFLICT (slug).
Events emitted: team.updated.
5.5 DELETE /workspaces/:workspaceId/teams/:teamId
- Auth: JWT (Admin or Owner). API key:
ADMINscope. - Pages in the team become unassigned (
teamId: null) — they are not deleted. Team-level permissions are revoked.
202 Accepted
{ "data": { "status": "deleting", "unassignedPageCount": 12 } }
Errors: 403 FORBIDDEN, 404 NOT_FOUND.
Events emitted: team.deleted, permission.changed (audit).
5.6 Team Members
Team membership rows (team_members) only exist for users who are already
workspace members.
GET /workspaces/:workspaceId/teams/:teamId/members
- Auth: JWT (any role; private teams require membership) / API key.
- Query:
cursor,limit.
200 OK
{
"data": [
{
"id": "tmm_01J9ZKP9I9J1L3M5N7P9Q1R3S5",
"userId": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"user": { "id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A", "name": "Ada Lovelace", "email": "ada@example.com" },
"role": "ADMIN",
"createdAt": "2024-10-16T14:25:00.000Z"
}
],
"nextCursor": null
}
POST /workspaces/:workspaceId/teams/:teamId/members
- Auth: JWT (Admin or Owner, or a team member). API key:
ADMINscope.
Request:
{ "userId": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0", "role": "MEMBER" }
201 Created — team member object.
Errors: 403 FORBIDDEN, 404 NOT_FOUND (user not a workspace member →
details: [{reason: "must_be_workspace_member"}]), 409 CONFLICT
(already in team).
Events emitted: team.member_added.
PATCH /workspaces/:workspaceId/teams/:teamId/members/:userId
- Auth: JWT (Admin or Owner, or a team member changing a lower-or-equal
role). API key:
ADMINscope.
Request: { "role": "ADMIN" } → 200 OK updated team member.
Events emitted: team.member_updated.
DELETE /workspaces/:workspaceId/teams/:teamId/members/:userId
- Auth: JWT (Admin or Owner, or a team member removing themselves).
API key:
ADMINscope.
204 No Content
Events emitted: team.member_removed.
6. curl Examples
# Create a workspace
curl -s -X POST https://factory.example.com/api/v1/workspaces \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Analytical Engines Co.","slug":"analytical-engines"}'
# Invite a member
curl -s -X POST https://factory.example.com/api/v1/workspaces/wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/invitations \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"email":"augusta@example.com","role":"MEMBER"}'
# Accept an invitation (new user)
curl -s -X POST https://factory.example.com/api/v1/invitations/accept \
-H 'Content-Type: application/json' \
-d '{"token":"itn_7a2f9c1e4b6d8f0a2c4e6f8b0d1a3c5e","password":"s3cure-Passphrase-42","name":"Augusta Ada King"}'
# Change a member role
curl -s -X PATCH https://factory.example.com/api/v1/workspaces/wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/members/usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0 \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"role":"GUEST"}'
# Create a team (inserted between existing teams via fractional position)
curl -s -X POST https://factory.example.com/api/v1/workspaces/wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/teams \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Platform Eng","slug":"platform","position":"a0V"}'
# Workspace settings
curl -s -X PATCH https://factory.example.com/api/v1/workspaces/wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/settings \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"theme":"dark","trashRetentionDays":14}'
7. Events Summary
| Operation | Domain event(s) |
|---|---|
| Create workspace | workspace.created, workspace.member_added |
| Update workspace | workspace.updated |
| Delete workspace | workspace.deleted |
| Update settings | workspace.settings_updated, permission.changed (audit when access changes) |
| Add member | workspace.member_added |
| Change member role | workspace.member_updated, permission.changed (audit) |
| Remove member | workspace.member_removed, permission.changed (audit) |
| Send invitation | workspace.invitation_sent |
| Accept invitation | workspace.invitation_accepted, workspace.member_added |
| Revoke invitation | workspace.invitation_revoked |
| Team CRUD | team.created / team.updated / team.deleted |
| Team member CRUD | team.member_added / team.member_updated / team.member_removed |