List Members
curl --request GET \
--url https://api.sublay.io/v7/:projectId/workspaces/:id/members \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.sublay.io/v7/:projectId/workspaces/:id/members"
headers = {"Authorization": "Bearer <token>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.sublay.io/v7/:projectId/workspaces/:id/members', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.sublay.io/v7/:projectId/workspaces/:id/members",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.sublay.io/v7/:projectId/workspaces/:id/members"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.sublay.io/v7/:projectId/workspaces/:id/members")
.header("Authorization", "Bearer <token>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.sublay.io/v7/:projectId/workspaces/:id/members")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_bodyWorkspace — Membership
List Members
The unified roster — one entry per distinct user, each with a reasons array
List Members
curl --request GET \
--url https://api.sublay.io/v7/:projectId/workspaces/:id/members \
--header 'Authorization: Bearer <token>'import requests
url = "https://api.sublay.io/v7/:projectId/workspaces/:id/members"
headers = {"Authorization": "Bearer <token>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
fetch('https://api.sublay.io/v7/:projectId/workspaces/:id/members', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.sublay.io/v7/:projectId/workspaces/:id/members",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.sublay.io/v7/:projectId/workspaces/:id/members"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.sublay.io/v7/:projectId/workspaces/:id/members")
.header("Authorization", "Bearer <token>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.sublay.io/v7/:projectId/workspaces/:id/members")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["Authorization"] = 'Bearer <token>'
response = http.request(request)
puts response.read_bodyReturns the roster as a single unified array, one entry per distinct user, each carrying a
The same roster read by a caller who cannot manage people here — the
Response (
Every path id on the workspaces bundle is checked for UUID shape before the route runs, so a malformed one is a plain
This route declares
See also: useFetchWorkspaceMembers · Fetch Member Standing
reasons array explaining why the user appears. Requires roster visibility — any relation to the workspace (owner, ancestor-owner, member, or reach-holder). A caller with no relation gets a 404.
Always returned in full — never paginated. include flags only add entries; they never change the shape. countOnly is the numbers-only escape hatch.
There is no
page or limit on this route, and sending them is a 400. This route declares exactly the query parameters listed below, and anything else is refused rather than ignored — ?page=2&limit=10 comes back as { "error": "Unrecognized keys: \"page\", \"limit\"", "code": "workspace/invalid-query" }. A paginating client therefore fails on its first call instead of receiving the entire roster every time and mistaking it for page 1.The response carries no pagination object precisely because there is none. If you need numbers rather than rows — a seat count, a badge — use countOnly=true, which returns per-reason counts and a distinct-user total without ever materializing the roster array. If you need a windowed UI, slice client-side after the single read.Path Parameters
string
required
The workspace UUID.
Query Parameters
string
Comma-separated add-on buckets. Default returns
owner + direct members. Options: ancestorOwners, reachHolders, descendants.boolean
When
true, returns per-reason counts + a distinct-user total instead of the array (never materializes the roster). Honors the same includes.string
Service/master keys only — the user to act as. Enforced: roster visibility and the authority-field fence below both evaluate against the named user, so a key acting as a plain member receives the fenced view. Omit it to read as the app itself (unfenced) — where “omit” means absent:
actingUserId: "" or null is a 400, not a fall-through to that mode. See Acting on behalf of a user and Shapes the server rejects.Those three, plus
projectId, are the whole query vocabulary of this route. Any other parameter is a 400 workspace/invalid-query — the two exceptions across the bundle are the cache-busters _ and _t, which are dropped before the request is read. An unrecognized value inside include is a different matter: it is ignored, not rejected.Reasons
Each entry’sreasons[] contains { type, ...detail } objects. A user present for several reasons is one entry carrying multiple reasons.
type | Detail | Counted as a seat? |
|---|---|---|
owner | — | Yes |
member | rank, relativeRank, capabilities, permissions, title, metadata | Yes |
ancestor-owner | viaWorkspaceId | No (informational) |
reach-holder | viaWorkspaceId, capabilities | No (informational) |
descendant-member | workspaceId, rank, capabilities — no relativeRank | Counted under the descendants include |
descendant-member rows carry rank but never relativeRank. Rank is per-workspace, so an offset measured against your standing on this node against a rank on another node would be arithmetic across two different ladders — a number that looks authoritative and is wrong. The field is uniformly absent on descendant rows rather than present only where a per-node anchor happens to have been resolved, so its presence never depends on who is asking. Their absolute rank is still there.Presence and fencing follow different rules on these rows, deliberately. relativeRank is uniformly absent from every descendant-member row for every caller, while the fence below is decided per node. Do not read the two as one rule. A field that shows up for some callers and not others is awkward to consume; an authority field that is fenced on some nodes and not others is a hole — so the cosmetic inconsistency is accepted and the security one is not.The authority-bearing detail fields are fenced, and the gate is evaluated PER NODE — not once per request.
capabilities, permissions, rank and relativeRank are omitted from a reason (absent — not null) on other users’ entries unless the caller, judged on the workspace that reason belongs to:- holds one of the four people-operating capabilities there —
invite,remove-member,edit-member-access,edit-member-profile; or - is that workspace’s
owneror anancestor-owner; or - is a service/master key acting as itself (naming no
actingUserId). A key that does name one is fenced exactly as that user is — see Acting on behalf of a user.
owner, member, ancestor-owner and reach-holder reasons, that workspace is the one in the path. For a descendant-member reason it is the descendant named by the reason’s own workspaceId, so the verdict is re-taken there. “Judged on that node” means your resolved standing there — which includes capabilities reaching in through an unbroken open inherit chain, and ownership from any ancestor. What it does not include is authority that stops at a sealed boundary.The caller’s own entry always carries them in full, on every node, so anyone can discover their own access. Who is on the roster — user, the reason types, title, metadata, viaWorkspaceId, workspaceId — stays visible to any relation. Treat the four fields as optional in your types.relativeRank is fenced with rank, not beside it: it is rank minus the caller’s own anchor, a number the caller already knows, so returning it to a fenced caller would reconstruct the fenced rank exactly.Worked example — operating people on the parent is not operating people on the child. Workspace
P has a sealed child C (inheritsFromParent: false). Bob holds invite on P; on C he is a plain rank-5 member with no capabilities. Dana is a rank-2 member of C.Bob calls GET /workspaces/P/members?include=descendants:- Dana’s
descendant-memberreason (nodeC) —workspaceIdonly. Bob still learns that Dana is onC, because he has a relation toCand its roster is unsealed to him; butrank,capabilitiesandpermissionsare omitted, because onChe operates nobody. HisinviteonPbuys him nothing there — the seal stops it from reaching down. - Bob’s own
descendant-memberreason (nodeC) — full. A caller always sees their own access. - Had
Cbeen open instead of sealed, Bob’sinvitewould reach intoC, he would clear the fence there, and Dana’s authority fields would be present — correctly.
include=descendants hands you exactly the authority fields that node’s own roster read (GET /workspaces/C/members) would hand you — never more.Response (array mode)
{
"data": [
{
"user": { "id": "u_pat" },
"reasons": [
{ "type": "member", "rank": 5, "relativeRank": 2, "capabilities": ["view", "invite"], "permissions": [], "title": "Frontend Lead", "metadata": {} },
{ "type": "ancestor-owner", "viaWorkspaceId": "ws_root" }
]
}
],
"total": 1
}
member reason keeps its identity fields but drops the authority-bearing ones:
{
"data": [
{
"user": { "id": "u_pat" },
"reasons": [
{ "type": "member", "title": "Frontend Lead", "metadata": {} },
{ "type": "ancestor-owner", "viaWorkspaceId": "ws_root" }
]
}
],
"total": 1
}
Response (countOnly=true)
{
"counts": { "owner": 1, "member": 12, "ancestorOwner": 2, "reachHolder": 3, "descendantMember": 40 },
"total": 13,
"distinctUsers": 52
}
total is the distinct-user seat number (owner + direct members), not the sum of buckets (buckets overlap). Ancestor-owners and reach-holders are never counted as seats.
Error Responses
Invalid Path Parameter — 400
Invalid Path Parameter — 400
{ "error": "Invalid workspace id: expected a UUID.", "code": "workspace/invalid-params" }
400 rather than a 500 from the database.Undeclared Query Parameter — 400
Undeclared Query Parameter — 400
{ "error": "Unrecognized key: \"page\"", "code": "workspace/invalid-query" }
include, countOnly, actingUserId and projectId, and refuses anything else rather than ignoring it. Two or more at once are named together: Unrecognized keys: "page", "limit". See Shapes the server rejects.
