Content visibility
The DFOS API follows the visibility choices spaces and authors make in DFOS and cannot be used to bypass them. Every resource carries its own gate; nothing is public by inference. The rules below describe the anonymous surface, which is most of the API.
Two gates on every read
A resource inside a space passes two gates: the space must have a public
profile, and the resource must be published in its own right. Without the public
profile the space's whole public surface goes dark at once, and turning the
profile back on republishes nothing. The cross-space listings (GET /v1/spaces,
GET /v1/events/upcoming) apply a stricter first gate: a space must be publicly
discoverable to appear there, so a space can be addressable by anyone holding
its identifier while staying out of every public index.
One 404 for everything hidden
A private resource, a deleted one, an unknown identifier, a malformed
identifier, and an identifier belonging to a different space all return the same
404 Not Found, and the API never says which. Listings answer with emptiness
instead: a filter naming something you cannot see returns an empty page, so an
empty result and a filtered-out result look alike too.
What each resource requires
| Resource | Served only when |
|---|---|
| Space | it has a public profile. Responses carry public profile and site information only — no membership, private activity, or viewer-specific state. |
| Post | it is shared to everyone, directly or in a public topic. |
| Page | the space has enabled it. Pages have no fold; a served page comes back whole. |
| Topic | it is live, carries a public post feed, and is readable by everyone. A chat topic never becomes an addressable public topic, even when opened to everyone. |
| Event | the space published it to everyone. Drafts, member-only events, and cancelled events are absent. |
| Product | its seller turned on its public purchase page and it is active, not archived, and addressable by a slug. |
| Release | the space published it and it is addressable by a slug. Its composed products, media, and events each stay behind their own rule, so an entry a caller cannot see is simply absent. |
- Topics. Filtering the posts listing by a topic you cannot see returns an
empty page rather than a
404— fetch the topic itself to tell "no public posts yet" from "not a public topic". A space with its Blog turned off serves no topics at all. See Topics. - Events. Attendee lists and RSVPs never reach this API; the only attendance
signal is an aggregate count of members who marked interest, and event cover
images are always public and permanently hosted.
GET /v1/spaces/{space}/eventsandGET /v1/spaces/{space}/events/{eventId}need a public profile and return404without one, the single-event read collapsing "no such event" into that same404.GET /v1/events/upcominghas no not-found at all: a space that is not publicly discoverable never contributes to it. See Events. - Products. An unpublished catalog is an empty list, not an error.
Everything a buyer receives after paying — deliverable files, confirmation
instructions, post-purchase links — is never served here, before or after a
purchase. Public ordering position is counted over the visible products only,
running
0, 1, 2, …with no gaps. See Products. - Pages. Inline media follows the same two classes as post media. See Pages.
Posts
A post containing a fold marker (<!--fold-->) returns only the content above
the fold, omits inline media that appears only below it, and sets folded to
true. Media on a readable post comes back inline in the two classes described
in Media URLs. List and single-post endpoints
apply identical rules: a post that cannot appear in a space's public feed
returns 404 Not Found when fetched directly.
The credential-gated routes
These serve the granting user's own data to an app they consented to, and are the only places on this API where private data is served at all:
GET /v1/profile— that user's own profile, including their email address.- The membership routes — the spaces and groups they belong to, naming private spaces that nothing else on this API will confirm exist.
GET /v1/credential— the presented credential and nothing else.GET /v1/feed, reached byread:posts— what the granting user can see in the spaces the grant reaches, including private spaces and paid content they hold.- The post comments route, also
read:posts— the thread as the granting user sees it, including in a private space the user belongs to.
They keep the collapsed 404. GET /v1/membership/{space} and
GET /v1/group-membership/{group} answer byte-identically for "there is no such
space" and "your user is not a member of it"; the read:posts content routes
return one 404 for a grant that does not reach a post's space, a post in
another space, a post the user cannot read, and a post that does not exist.
A credential widens what a member can address; it never widens existence
GET /v1/spaces/{space}/posts and GET /v1/spaces/{space}/posts/{postId} serve
the anonymous projection to everybody, and the granting user's projection to a
caller whose credential covers that space with read:posts.
- A private space is addressable by its own members. On the routes that
accept a credential — posts, comments, and media — a space with no public
profile resolves for a caller whose credential covers it — or who presents a
bare identity proof — and who is an active member of it. The anonymous routes
(the space profile, pages, topics, events, products, releases) still need a
public profile. Everyone else gets the same
404as a space that does not exist: an anonymous caller, a grant that does not reach it, a user who is no longer active there. A broken or revoked credential is refused the same way on a private space as on a missing one, so the status code says nothing about whether the space is real. - A grant that does not cover the space returns the anonymous bytes — not a
403, the identical response — as does a covering grant whose user is no longer an active member there. A credential is a ceiling on what an app may read; the user's own standing is the floor. Losing access looks like silence, so read your coverage fromGET /v1/credential. - The fold rule is unchanged. An anonymous read still stops at the fold; a member projection carries the full body and every attachment only where the granting user genuinely reads the post.
A space id learned from a membership response is still just an id: that space's pages and members stay unreadable, and its posts open only to a grant that names it. Every other endpoint stays anonymous and viewer-independent whether or not a credential is presented. See Authentication.
Writing never widens existence either
Writes sit behind the same two gates and collapse into the same 404: a post in
another space, a post in a topic the granting user cannot reach, and a post that
does not exist are one answer. The addressed space is a different question, and
its status follows the space's visibility: on a space with a public profile, a
grant that does not cover it is a 403, and so is not being a member — never the
anonymous projection, because there is no anonymous write to degrade to. On a
space without one, both are the same 404 as a space that does not exist, and a
replayed write is a 409 only for a member. Writes also reach less than reads: a
grant covering a space with
write:posts reaches the granting user's own posts there and nothing else, and
another member's content is 403 whatever that user's role. Nothing in this API
moderates.
A bare identity proof
writes as well as it reads: a signing key added under Settings → DFOS
Protocol can post, comment, and upvote as you with no credential and no
consent screen. There is nothing in the DFOS app to revoke: revocation is a
chain operation. Remove the key from your
identity and the next proof it signs stops verifying, so a leaked signing key is
full write authority as you until you do. An exit key cannot write: a key whose
only role is controller is a continuity instrument, and this API answers 403
when one signs a write. Use an ordinary signing key for content.