Skip to main content
The workspaces module is the developer’s authorization-server SDK for the Workspaces bundle. It exposes the full surface: workspace CRUD, membership management, the invitation lifecycle, and the service-key authority read you use to make your own app-level decisions.
Requires the workspaces bundle. See Bundles to install it. Because this is a service-key SDK, every call accepts an actingUserId — the user the action is performed as (the service key can act as any user). Whether it is required or optional is per route — see the notes below. For fetchWorkspaceAuthority, actingUserId is the user whose standing is resolved.
This SDK is not required in order to manage workspaces. All 21 endpoints are equally available to a browser or mobile app through the @sublay/core hooks and @sublay/js, acting as the signed-in user and subject to the same ownership / capability / rank rules. What the node SDK adds is the acting-user field actingUserId — performing an action as some other named user — which only a service/master key may do.
Naming an actingUserId means being that user. It is not a hint or an audit tag — it binds. Every check the named user would face runs: the capability gate, the owner-only gate, the rank guard, no-escalation, and the roster/standing fencing. If that user would be refused, so are you. Omitting actingUserId is the only unbounded mode — that request is the app itself, not a person — and it exists only on the routes that have an unbounded path at all (see the two notes below).So an unrestricted backend action should be performed as the workspace owner, not by omitting the actor: the owner resolves the full capability vocabulary and apex rank, and the resulting webhooks carry that user id rather than nothing. Treat that id as a claim, not proof — a key may name any UUID, and ownerId is not a foreign key, so a deleted owner’s id still resolves owner authority. Two consequences bite first-time integrators — acceptWorkspaceInvite requires the invite to be actually addressed to the named user and that user’s email to be verified, and a rank tier cannot grow itself (see createWorkspaceInvite). Full rule: Acting on behalf of a user.
On many routes the actor is required, not optional. These ten routes have no unbounded path on the server — their result belongs to a specific person, so a key must name one:createWorkspace, fetchManyWorkspaces, fetchMyWorkspaceInvites, fetchWorkspaceAuthority, acceptWorkspaceInvite, declineWorkspaceInvite, leaveWorkspace, updateWorkspaceMember, removeWorkspaceMember, removeWorkspaceMemberFromSubtree.Omitting actingUserId on any of them is a 400 workspace/missing-user-id, never a bypass. actingUserId is typed required on all ten.
Four more are typed required here even though the REST endpoint allows omission. updateWorkspace, updateWorkspaceInheritFlag, deleteWorkspace and transferWorkspaceOwnership are guard-only routes: over REST, a key that omits actingUserId runs as the app itself and passes the owner-only gate unbounded. This SDK nevertheless declares actingUserId required on all four, so TypeScript will not let you omit it.That is an SDK-level guardrail, not a different server rule — these are the four most destructive owner-only calls (deleteWorkspace cascades the whole subtree), and every one of them is better off carrying a user id. If you genuinely need the unbounded path on one of them, call the REST endpoint directly; the server accepts it.On the remaining seven — fetchWorkspace, fetchWorkspaceMembers, fetchWorkspaceMemberStanding, createWorkspaceInvite, fetchWorkspaceInvites, revokeWorkspaceInvite, resendWorkspaceInviteactingUserId is optional in both the SDK and the API, and omitting it runs unbounded.
Absent, not falsy — and undeclared fields are refused, not ignored. actingUserId: "" or null is a 400, not a fall-through to the app-itself mode, so actingUserId: user?.id ?? "" fails loudly rather than quietly acquiring full authority.Every workspaces endpoint also declares its fields exactly, and refuses any top-level field it does not declare — in the body and in the query string alike, for every caller — with { "error": "Unrecognized key: \"onBehalfOf\"", "code": "workspace/invalid-body" } (plural Unrecognized keys: "a", "b" for two or more, and workspace/invalid-query when the offender was in the query string). So onBehalfOf, impersonate and acting_user_id all fail at the wire rather than being dropped on the way to an unbounded call. A userId anywhere in a body or query is refused with a message of its own — the sole exception is the invitee in createWorkspaceInvite’s body, and even there a query ?userId= is refused like everywhere else. A non-UUID id in a path is a 400 workspace/invalid-params rather than a 500. Full list with the reasoning: Shapes the server rejects.One of those rules cannot bite you through this SDK: a body whose content-type no parser reads is refused on the unbounded path, and this SDK serializes every body it sends as application/json. It is a hazard for hand-rolled fetch() calls, not for sublay.workspaces.*.
Pass only the documented fields — the props object is sent whole. createWorkspace and its siblings hand your object straight to the request body, so a convenience spread carries whatever else it holds into the request, and the endpoint refuses it:
Your own data belongs in metadata, which is opaque to Sublay and accepts any JSON. Strictness governs top-level keys only, so nothing inside metadata is inspected.
Read the concepts first: capabilities vs permissions, rank, reach & ownership, and the identity-matched, no-token invite model.

Workspace lifecycle

createWorkspace

Root creation requires the acting user to have a verified email. Child creation requires the create-sub-workspace capability on the parent; the creator becomes the child’s owner.
A service key does NOT bypass the verified-email gate. The actingUserId you act as must itself be verified, or the call returns 403 workspace/email-not-verified. This matches acceptWorkspaceInvite and transferWorkspaceOwnership — all three doors into ownership check it, so every workspace owner has a confirmed email. A key lets you act as a user; it does not make that user verified.
string
required
The user the workspace is created as (becomes the owner).
string
required
Display name (1–100 characters).
object
Opaque developer data.
string
Parent for child creation; absent/null → root.

fetchWorkspace

string
required
The workspace UUID.
string
Comma-separated include flags, sent as a query param. memberCount adds the workspace’s direct member count; omit it and the field is absent. An unrecognized flag value is ignored.
string
Optional — the user to act as. Sent as a query param. Visibility resolves against them, so a key acting as someone with no relation to this workspace gets that user’s 404. Omit to read as the app itself.

fetchManyWorkspaces

Lists a user’s direct-membership + owned workspaces (paginated).
string
required
The user whose workspaces to list.
number
number
string
Comma-separated include flags, sent as a query param. memberCount adds a memberCount to every row — the workspace’s direct member count, resolved in one grouped query over the returned page rather than one request per row. Omit it and the field is absent from each row.

updateWorkspace

Edits name/metadata (gated by edit-workspace or owner). Does not flip the inherit flag.
string
required
string
required
The user to act as — must hold edit-workspace on this workspace, or own it. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded).
string
object

updateWorkspaceInheritFlag

Owner-only in both directions; subject to the enforced-default lock.
string
required
string
required
The user to act as — must own this workspace or an ancestor. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded).
boolean
required

deleteWorkspace

Owner-only; cascades the subtree and fires workspace.deleted per deleted workspace — and no workspace.member.removed events for the memberships it tears down (see Webhooks).
string
required
string
required
The user to act as — must own this workspace or an ancestor. This call cascade-deletes the whole subtree, so name the owner deliberately. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded) — which is precisely why the type does not.

transferWorkspaceOwnership

string
required
string
required
The user to act as — must own this workspace or an ancestor. Required by this SDK’s types; the REST endpoint also accepts it omitted (runs unbounded).
string
required
The new owner (any verified user).
string
"demote" or "remove". Defaults to "remove" when omitted.
number
On demote, the ex-owner’s rank (absolute only). Defaults to one rung below the acting user’s anchor0 when the actor is apex (an owner or ancestor owner with no member row here, which is the usual case), or one below their own row when the named actingUserId sits in this workspace’s ladder. (A key naming nobody also anchors at apex, but this SDK requires actingUserId here, so that shape is only reachable over REST.)
string[]

Membership

fetchWorkspaceMembers

Returns the unified roster (WorkspaceRosterResponse), or WorkspaceRosterCountsResponse when countOnly is set.
The roster fences the authority-bearing reason fields (rank, relativeRank, capabilities, permissions) from callers who cannot manage people on the workspace — per node, so with include=descendants each descendant-member reason is judged against the acting user’s resolved standing on that descendant, and authority over the parent does not unfence a sealed child. A service key acting as itself is never fenced — it always sees them. Pass actingUserId and the fence evaluates against that user instead, so a key acting as a plain member receives the same partial roster that member would. The SDK types mark the four fields optional for that reason.A member reason carries both rank coordinates: absolute rank, and relativeRank — that member’s position as an offset from the caller, meaning the acting user when you name one. descendant-member reasons carry rank only, since their ranks belong to another node’s ladder.
string
required
string
ancestorOwners, reachHolders, descendants.
boolean
string
Optional — the user to act as. Sent as a query param. Roster visibility and the authority-field fence both evaluate against them. Omit to read as the app itself (unfenced).

fetchWorkspaceMemberStanding

string
required
string
required
The user whose standing to read.
string
Optional — the user to act as. Sent as a query param. Roster visibility and the authority-field fence both evaluate against them (and a caller always sees their own access, so actingUserId === targetUserId is never fenced). Omit to read as the app itself.
Returns WorkspaceMemberStanding. On its user field only id is guaranteed (WorkspaceStandingUser): the server returns the full user record when the user row still exists and falls back to { id } alone when it does not — a deleted user with a lingering membership row is a reachable case. Read the rest defensively (standing.user.username ?? "Deleted user"). reasons is an array of structured { type, viaWorkspaceId? } entries — the same shape a roster entry’s reasons carries — so you learn not just the kind of standing but which ancestor grants it. capabilities / permissions / rank / relativeRank are typed optional because the server omits them for callers who cannot manage people (relativeRank is fenced with rank — it is rank minus an anchor the caller already knows). A service key acting as itself is never fenced, so on a call with no actingUserId they are always present; pass one and the fence evaluates against that user, who may well not see them.

updateWorkspaceMember

Powerful fields require edit-member-access + rank rules + no-escalation; cosmetic fields require edit-member-profile. Rank moves in either coordinaterank (absolute) or relativeRank (an offset from actingUserId), never both. Omitting both means rank unchanged. See choosing a rank.
string
required
string
required
The member being edited (path).
string
required
The acting user (body). Required by the server here, not just by the types: this route has no unbounded path, so omitting it is a 400 workspace/missing-user-id. Every gate — both capability tiers, the rank guard, the assign rule, no-escalation — runs against this user. Act as the owner for an unrestricted edit.
string[]
string[]
number
Absolute. Mutually exclusive with relativeRank; omit both to leave rank unchanged.
number
An offset from the acting user1 = one rung below them. Must be >= 1. Anchored on their member row on this workspace if they hold one, apex otherwise, so a key acting as the owner resolves relativeRank: 1 to rank 0. Resolved to an absolute rank at write time and frozen. No default.
string
object

removeWorkspaceMember

Requires remove-member (rank-bounded); sole-owner protected.
string
required
string
required
The member to remove.
string
required
The acting user.

leaveWorkspace

Removes the given user’s own membership on this node only.
string
required
string
required
The user leaving.

removeWorkspaceMemberFromSubtree

Blocks with 409 workspace/owns-descendants (and a report) if the target owns any descendant workspace.
skippedCount reflects the acting user, not the key. The sweep is partial for a non-owner actor, and actingUserId is required on this call — a key always names someone, and is bound by their reach and their per-node rank. Name a non-owner admin and you can get a partial sweep, exactly as that admin would; name the owner for the god-path that clears every descendant. Always assert on skippedCount rather than assuming removedCount means the user is fully offboarded.
string
required
string
required
The user to offboard.
string
required
The acting user.

Invitations

createWorkspaceInvite

Requires the invite capability. Here userId/username (if used) address the invite target; the inviter is actingUserId, or the app itself when you omit it. Email matching against existing accounts is case-insensitive. The invited grant is floored by the inviter’s own standing: no-escalation on capabilities/permissions, and a rank strictly below the inviter’s own. Both floors are skipped for an owner, and for a key naming nobody. The invited rank is named in either coordinaterank (absolute) or relativeRank (an offset from the inviter) — and they are mutually exclusive. Supplying neither applies relativeRank: 1, one rung below the inviter, which is well defined for every actor shape (a key naming nobody anchors at apex, so its default lands on rank 0) and by construction never trips the rank floor.
A tier cannot grow itself. Acting as a rank-5 manager with a direct member row you can invite rank 6, 7, 8… but never another rank 5, and no ranked member clears the floor for rank 0. The floor binds only an inviter with a direct member row on the workspace — an owner or ancestor owner, a reach-holder with no direct row, and a key naming nobody all skip it entirely. If your product has a server-driven “add a manager” action, act as the owner for it. See Acting on behalf of a user.
The project must have workspaces.inviteAcceptUrl configured, or this call fails with 409 workspace/missing-invite-accept-url and persists nothing. It has no default, and a service key does not exempt you. Applies even when the invitee already has an account. See Project settings.
string
required
string
One of email/userId/username.
string
Invite target user id.
string
Invite target username.
string[]
string[]
number
Absolute. Not required — mutually exclusive with relativeRank, and omitting both applies the relativeRank: 1 default.
number
default:"1"
An offset from the inviter — 1 = one rung below them. Must be >= 1. Anchored on the inviter’s member row on this workspace if they hold one, apex otherwise. A snapshot: resolved to an absolute rank here and frozen, so a later promotion or demotion of the inviter does not move the pending invite. Note the collision caveat, which is at its sharpest here: a key acting as itself (no actingUserId) has no member row, so it anchors at apex and the default mints rank 0 — the most senior rung — for every invitee. A backend invite flow that wants people at the bottom of the ladder must name an absolute rank, or name an actingUserId who actually sits in the ladder. For an in-ladder inviter the default instead puts a whole cohort on one rung, where equals cannot manage each other. See choosing a rank.
string
string
Optional — the inviter to act as. The no-escalation and rank floors run against their standing. Omit to invite as the app itself (unbounded; invitedBy then falls back to the workspace owner). Distinct from userId, which is the invitee.

fetchWorkspaceInvites

The workspace’s outbox — the live pending invites it has issued. Requires invite (or owner). Not to be confused with fetchMyWorkspaceInvites, which is one user’s inbox of invites addressed to them across every workspace.
string
required
string
Optional — the user to act as. Sent as a query param. invite (or ownership) is required of them. Omit to read as the app itself.

revokeWorkspaceInvite

string
required
string
required
string
Optional — the user to act as. invite (or ownership) is required of them. Omit to act as the app itself.

resendWorkspaceInvite

Refreshes a lapsed pending invite (14-day expiry) and resends the email. Terminal invites → 409. Returns the refreshed WorkspaceInvitation, carrying the new expiresAt. Like createWorkspaceInvite, it requires workspaces.inviteAcceptUrl on the project → otherwise 409 workspace/missing-invite-accept-url with expiresAt untouched.
string
required
string
required
string
Optional — the user to act as. invite (or ownership) is required of them. Omit to act as the app itself.

acceptWorkspaceInvite

Identity-matched + verified-email. Idempotent when already a member/owner.
string
required
string
required
The accepting user (must be the target and verified).

declineWorkspaceInvite

Identity-matched + verified-email — the same gate as accept, so an unverified squatter on someone else’s address cannot burn their invite. A service key does not bypass it: the actingUserId you act as must itself be verified, or the call returns 403 workspace/email-not-verified.

fetchMyWorkspaceInvites

The user’s inbox — their live pending invites addressed to them across every workspace (matched by userId). The per-workspace outbox counterpart is fetchWorkspaceInvites.

Authority-as-a-service

fetchWorkspaceAuthority

The service-key authority read — the crux of the server-side surface. Resolve any user’s standing on a workspace, then run your own permission check with a one-line .includes().
string
required
string
required
The acting user whose resolved standing to read.
reasons entries carry viaWorkspaceId on ancestor-owner / reach-holder, naming the ancestor responsible — so when a user has authority you did not expect, you can tell exactly which node to go fix. capabilities is the fully-resolved set, and view is implied by every other capability: any user with standing carries it, while a user with no relation at all comes back with reasons: [] and an empty capability set. There is no relativeRank on this read — it is an offset from the caller, and here the caller is the subject, so rank is the coordinate it reports. Sublay never consumes your opaque permissions — there is deliberately no ?permission= check or can() helper.