Skip to main content

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.

RouteAnswers
GET /v1/membershipsWhich spaces is this user in?
GET /v1/membership/{space}Is this user in this space?
GET /v1/group-membershipsWhich 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 domain here 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​

SituationListed
Active member of a space, public or privateYes
Owner or admin of a spaceYes
Left the spaceNo
Deactivated or banned in the spaceNo
Space was deletedNo
Invited but never joinedNo

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})

FieldNotes
idEntity id (space_…). Canonical and stable — store this.
didThe space's protocol DID. Also canonical and stable.
domainEffective subdomain. A mutable alias; may not resolve publicly.
displayNameSpace name, or null.
descriptionSpace description, or null.
avatarUrlPermanent CDN URL, or null.
linksOrdered profile links (may be empty).
memberCountSummaryWorded 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})

FieldNotes
idEntity id (group_…).
didThe group's own protocol DID — never its space's.
nameGroup name.
descriptionGroup description, or null.
avatarUrlPermanent CDN URL, or null.
colorA 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.
memberCountExact number of active members.
spaceIdEntity id of the space holding this group.
spaceDidProtocol 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 carry read:memberships for this host, or it was revoked or expired. A read:profile or read:email grant does not open these routes. A check answers 403 rather than 404 here: 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.