Skip to main content

Memberships

Four routes read the memberships of the user who granted your app access. All four are credential-gated, like GET /v1/profile, and all four require the read:memberships action. The credential comes from 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?

If you are gating on membership — "let them in if they belong to our space" — use a check. One request, one answer, no pagination. The walks are for building a picture of where someone belongs.

Spaces

curl https://api.dfos.com/v1/memberships \
-H 'Authorization: DFOS <request proof>' \
-H 'X-Credential: <credential>'
{
"items": [
{
"space": {
"id": "space_6encc4akrze2ah9kntzd9t",
"did": "did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc",
"domain": "metalabel",
"displayName": "Metalabel",
"description": "A tool for collective creation.",
"avatarUrl": "https://cdn.dfos.com/…",
"links": [],
"memberCountSummary": "a few hundred members"
},
"role": "member",
"groupCount": 1,
"joinedAt": "2026-02-14T09:11:00.000Z"
}
],
"nextCursor": null,
"previousCursor": null,
"totalCount": 1
}

groupCount is how many groups the user belongs to inside that space. It is usually 0 — most members belong to no group. To get the groups themselves, walk GET /v1/group-memberships; the two lists are separate on purpose (see Correlating the two).

Groups

curl https://api.dfos.com/v1/group-memberships \
-H 'Authorization: DFOS <request proof>' \
-H 'X-Credential: <credential>'
{
"items": [
{
"group": {
"id": "group_79h6z77had2kc68ffdkhac",
"did": "did:dfos:79h6z77had2kc68ffdkhac9e4tv3rf",
"name": "Editors",
"description": "Runs the publication",
"avatarUrl": null,
"color": "violet",
"memberCount": 7,
"spaceId": "space_6encc4akrze2ah9kntzd9t",
"spaceDid": "did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc"
},
"role": "admin",
"joinedAt": "2026-03-02T18:40:00.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=metalabel' …

Groups give exact counts; spaces do not

group.memberCount is an exact integer. space.memberCountSummary is a worded bucket"a few hundred members", never a number. That asymmetry is deliberate.

A space's population is ambient. It is the size of a room, and DFOS publishes it as atmosphere rather than as a figure to watch — every public surface says "dozens of members" and means it. A group is an operational unit: it is the editors, the moderators, a paid tier. "How many editors does this publication have" has a real answer that its own members already know, and rounding it into a bucket would make the number useless for the automation these routes exist to serve.

What makes the exact count safe to serve is the gate itself. Reaching this field at all requires a read:memberships credential that a member of that group signed. The privileged access is the endpoint.

Correlating the two

group.spaceId and group.spaceDid are flat references, not an embedded space object. To reassemble the full picture, walk both lists and join them 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);

Two flat pages beat one page that is secretly a join: each stays a predictable size, and groupCount answers the common question without loading anything.

Checking one space or group

# by subdomain, entity id, or protocol DID
curl https://api.dfos.com/v1/membership/metalabel …
curl https://api.dfos.com/v1/membership/space_6encc4akrze2ah9kntzd9t …
curl https://api.dfos.com/v1/membership/did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc …

# groups have no subdomain — entity id or protocol DID
curl https://api.dfos.com/v1/group-membership/group_79h6z77had2kc68ffdkhac …

A hit returns one membership object — exactly the item shape the matching list emits, not a page.

A miss returns 404, and this is the part to design around:

:::warning The 404 is collapsed on purpose "There is no such space" and "your user is not a member of it" are the same 404, byte for byte. There is no way to tell them apart, and there never will be.

The credential you hold discloses the user's own memberships. It does not disclose the existence of anything else — so the check matches the identifier against that user's membership rows rather than looking a space up. The two outcomes are literally the same code path. :::

So treat 404 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 walks' space filter behaves the same way for the same reason: a space the user is not in and a space that does not exist both give you an empty page, never a 404 and never two distinguishable answers.

:::tip Sign the proof over the full target A request proof binds the path including the query string, byte for byte. Sign /v1/group-memberships?space=metalabel, not /v1/group-memberships — adding a parameter after signing breaks the binding and the request is refused with 401. The same goes for the identifier in a check's path. :::

It lists private spaces

This is the point of the scope, and the one thing to understand before you ask for it.

Everywhere else on this API, a space with no public front door is indistinguishable from a space that does not exist — that is a deliberate, permanent property of the anonymous surface. These routes are different: they name every space the granting user is currently in, private and unlisted ones included, because the person who is in them told DFOS to tell you. The consent screen says so in DFOS's own words before they approve.

Two consequences worth designing around:

  • Treat the response as sensitive. It is a map of where one person spends their time. 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 effective subdomain is real but the public site is not; do not build a link to it and assume a visitor can follow it.

No other route on this API will confirm any of this. Learning a space id here does not let you read that space's posts, pages, or members anonymously — the membership grant is a statement about the user, not a key to the room.

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: active memberships in live groups, inside spaces the user is still an active member of.

Filtering by role

Both walks take a repeatable role parameter:

# 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' …

Repeating the parameter means either role. totalCount reflects the filtered set, so a filter matching nothing is an empty page with totalCount: 0 — not an error.

Hold role (and space) constant while walking cursors. A cursor encodes a position in the filtered set, not the filter itself.

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. "a few hundred members" — never an exact number.

Space membership

FieldNotes
roleowner, admin, or member.
groupCountGroups the user belongs to inside this space. Often 0.
joinedAtWhen the membership began (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. violet), 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

FieldNotes
roleowner, admin, or member.
joinedAtWhen the group membership began (ISO 8601 UTC).

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 paginationlimit (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. The order is stable, so a walk never skips or repeats — and a new membership appends to the end rather than shifting every page you have already read.

Calling it

Same two headers, the same refusal codes, and the same revocation behavior as GET /v1/profile — and, as there, reading your OWN memberships needs no credential at all: sign an identity proof with one of your own keys and send Authorization alone, per Reading your own data needs no credential. How to get a credential to call these with: Set up § 7, asking for read:memberships. The one difference from /v1/profile is which action opens them:

  • 401 — your proof was wrong. Sign a fresh one and retry.
  • 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. Retrying will not help. Note that a check answers 403 rather than 404 in this case: an unauthorized caller is told its grant is wrong, never handed the membership answer's shape.
  • 404 — (checks only) not a member, or no such space or group. The two are indistinguishable by design.
  • 503 — DFOS could not complete verification. Retry with backoff.

To find out what your credential actually covers before calling, GET /v1/credential will tell you.

Caching and privacy

Cache-Control: no-store, never shared-cached — these responses describe one person. Don't put them behind a shared cache or CDN of your own either.

Membership changes constantly, and these routes are cheap; read them when you need them rather than mirroring the whole graph. If you do keep a copy, treat a 403 as the signal to delete it: revoking ends your access to the data, and the user's expectation is that it ends your copy of it too.