Memberships
Four routes read the memberships of the user who granted your app access. All
are credential-gated like GET /v1/profile and all require the
read:memberships action, granted through
Sign in with DFOS.
| Route | Answers |
|---|---|
GET /v1/memberships | Which spaces is this user in? |
GET /v1/membership/{space} | Is this user in this space? |
GET /v1/group-memberships | Which groups is this user in, across every space? |
GET /v1/group-membership/{group} | Is this user in this group? |
To gate on membership, use a check: one request, one answer, no pagination.
Spaces
curl https://api.dfos.com/v1/memberships \
-H 'Authorization: DFOS <request-proof>' \
-H 'X-Credential: <credential>'
{
"items": [
{
"space": {
"id": "space_vnzfk7hth9vadc3daahd48",
"did": "did:dfos:9ctvrdn9vedda7efetrhcdakfh4cr2k",
"domain": "home",
"displayName": "DFOS",
"description": "The official DFOS of DFOS",
"avatarUrl": "https://dfos.imgix.net/media/public/8e87h8c37f2vz3v6h7h3at-frame-143726546.png",
"links": [
{
"url": "https://dfos.com/",
"label": null,
"title": "Dark Forest OS",
"description": null,
"imageUrl": "https://dfos.imgix.net/media/public/ne979cake8har94728rne7-dfos-private-internet.png"
}
],
"memberCountSummary": "thousands of members"
},
"role": "member",
"groupCount": 1,
"joinedAt": "2026-01-14T19:02:11.000Z"
}
],
"nextCursor": null,
"previousCursor": null,
"totalCount": 1
}
groupCount counts the groups the user belongs to inside that space, usually
0. The groups themselves come from the separate group walk.
Groups
curl https://api.dfos.com/v1/group-memberships \
-H 'Authorization: DFOS <request-proof>' \
-H 'X-Credential: <credential>'
{
"items": [
{
"group": {
"id": "group_4kt7cn9vza3fe2h8dr6ac4",
"did": "did:dfos:7ha4dr2kc9vt3fe8n6zad4hcv7t2en9",
"name": "Developers",
"description": "Building on the DFOS API and protocol.",
"avatarUrl": null,
"color": "cyan",
"memberCount": 42,
"spaceId": "space_vnzfk7hth9vadc3daahd48",
"spaceDid": "did:dfos:9ctvrdn9vedda7efetrhcdakfh4cr2k"
},
"role": "admin",
"joinedAt": "2026-01-14T19:02:11.000Z"
}
],
"nextCursor": null,
"previousCursor": null,
"totalCount": 1
}
This list spans every space the user is in. Pass space to scope it to one:
curl 'https://api.dfos.com/v1/group-memberships?space=home' …
group.memberCount is an exact integer; space.memberCountSummary is a worded
bucket, never a number. No DFOS surface carries an exact space member count.
Correlating the two
group.spaceId and group.spaceDid are flat references, not an embedded space
object. Join the two walks on spaceId:
const spaces = await walk('/v1/memberships');
const groups = await walk('/v1/group-memberships');
const bySpace = new Map(spaces.map((m) => [m.space.id, { ...m, groups: [] }]));
for (const g of groups) bySpace.get(g.group.spaceId)?.groups.push(g);
Checking one space or group
# by subdomain, entity id, or protocol DID
curl https://api.dfos.com/v1/membership/home …
curl https://api.dfos.com/v1/membership/space_vnzfk7hth9vadc3daahd48 …
curl https://api.dfos.com/v1/membership/did:dfos:9ctvrdn9vedda7efetrhcdakfh4cr2k …
# groups have no subdomain — entity id or protocol DID
curl https://api.dfos.com/v1/group-membership/group_4kt7cn9vza3fe2h8dr6ac4 …
A hit returns one membership object — the same item shape the matching list
emits, not a page. A miss returns 404, and "there is no such space" and "your
user is not a member of it" are the same 404, byte for byte: a check confirms
a membership the credential already discloses, never that a space or group
exists. Treat it as "no", not as an error.
const res = await fetch(`https://api.dfos.com/v1/membership/${space}`, { headers });
if (res.status === 404) return false; // not a member — or no such space
if (res.status === 403) throw new Error('grant does not cover read:memberships');
return true;
The group walk's space filter collapses the same way: a space the user is not
in and a space that does not exist both give an empty page.
GET /v1/memberships takes no space filter — it already lists every space —
and sending one is a 400.
A request proof binds the path including the query string, byte for byte.
Sign /v1/group-memberships?space=home, not /v1/group-memberships; adding a
parameter after signing is refused with 401. The same goes for the identifier
in a check's path.
It lists private spaces
These routes name every space the granting user is currently in, private and unlisted ones included — everywhere else on this API such a space is indistinguishable from one that does not exist. The consent screen says so before the user approves.
- Treat the response as sensitive. Store the minimum your app uses, and delete it when the grant ends.
- A
domainhere may not resolve publicly. For a private space the subdomain is real but the public site is not.
Learning a space id here grants no anonymous read of that space's posts, pages, or members.
What counts as a membership
| Situation | Listed |
|---|---|
| Active member of a space, public or private | Yes |
| Owner or admin of a space | Yes |
| Left the space | No |
| Deactivated or banned in the space | No |
| Space was deleted | No |
| Invited but never joined | No |
Group memberships follow the same rule, inside spaces the user is still in.
Filtering by role
Both walks take a repeatable role parameter; repeating it means either
role.
# spaces this user runs
curl 'https://api.dfos.com/v1/memberships?role=owner&role=admin' …
# groups they merely belong to
curl 'https://api.dfos.com/v1/group-memberships?role=member' …
totalCount reflects the filtered set, so a filter matching nothing is an empty
page with totalCount: 0. Hold role and space constant while walking
cursors: a cursor encodes a position in the filtered set, not the filter.
Fields
Space (on GET /v1/memberships and GET /v1/membership/{space})
| Field | Notes |
|---|---|
id | Entity id (space_…). Canonical and stable — store this. |
did | The space's protocol DID. Also canonical and stable. |
domain | Effective subdomain. A mutable alias; may not resolve publicly. |
displayName | Space name, or null. |
description | Space description, or null. |
avatarUrl | Permanent CDN URL, or null. |
links | Ordered profile links (may be empty). |
memberCountSummary | Worded scale, e.g. "thousands of members" — never an exact number. |
Space membership: role (owner, admin, or member), groupCount, and
joinedAt (ISO 8601 UTC; the list is ordered by it).
Group (on GET /v1/group-memberships and GET /v1/group-membership/{group})
| Field | Notes |
|---|---|
id | Entity id (group_…). |
did | The group's own protocol DID — never its space's. |
name | Group name. |
description | Group description, or null. |
avatarUrl | Permanent CDN URL, or null. |
color | A named palette token (e.g. cyan), or null. Not a CSS value — map it through your own palette, and tolerate a token you don't recognize. |
memberCount | Exact number of active members. |
spaceId | Entity id of the space holding this group. |
spaceDid | Protocol DID of that space. |
Group membership: role (owner, admin, or member) and joinedAt.
Roles are an open enum like every enum on this API: treat an unrecognized value as an opaque string rather than an error.
Pagination
Standard cursor pagination — limit (default 20,
max 100), after, before. Pass nextCursor back verbatim; never parse or
construct one. totalCount is
the number of items matching your request. Both walks are ordered by joinedAt
ascending with the entity id as a tiebreak, so a walk never skips or repeats and
a new membership appends to the end.
Calling it
Same two headers, refusal codes, and revocation behavior as
GET /v1/profile, including reading your own
memberships with an identity proof and no credential, per
Reading your own data needs no credential.
Ask for the grant in
Set up § 7.
Two codes differ from /v1/profile:
403— your credential does not carryread:membershipsfor this host, or it was revoked or expired. Aread:profileorread:emailgrant does not open these routes. A check answers403rather than404here: an unauthorized caller is never handed the membership answer's shape.404— (checks only) not a member, or no such space or group. The two are indistinguishable.
GET /v1/credential tells you what your credential covers
before you call.
Caching and privacy
Cache-Control: no-store, never shared-cached; don't put these behind a shared
cache or CDN of your own either. Membership changes constantly and these routes
are cheap, so read them when you need them rather than mirroring the graph. If
you do keep a copy, treat a 403 as the signal to delete it.