Skip to main content

Posts

Posts live under a space. Four endpoints read them:

  • GET /v1/spaces/{space}/posts — list a space's posts.
  • GET /v1/spaces/{space}/posts/{postId} — fetch one post.
  • GET /v1/feed — the granting user's posts across every space a grant reaches.
  • GET /v1/spaces/{space}/posts/{postId}/comments — a post's comment thread.

The first two are optional-auth: no credential returns the public projection, a credential covering that space returns what the granting user sees. The last two are gated, with no public projection. Twelve more routes write posts, comments, upvotes, and the media they carry.

See Content visibility for what "public" means and how folding works, and Conventions for the four outcomes of an optional-auth call.

Listing a space's posts

GET /v1/spaces/{space}/posts returns posts newest first, cursor-paginated (see Conventions). Each item is a compact card; the body is only on the single fetch.

FieldValue
id, slugIdentifiers. Address the post by id.
titlenull on an untitled post.
formatshort-post or long-post. An open enum — render an unrecognized value as a generic post.
displayTitleA ready-to-render heading: the title, else a short excerpt of the above-fold body, else null. Never from below-fold content.
excerptA truncated, markdown-stripped preview of the above-fold body.
authorThe author identity, or null.
coverA cover image, when the post has one (see Media fields).
upvoteCount, commentCountCounts on the post.
isPinnedWhether the space pinned this post. The listing is ordered purely by recency; hoist pinned posts yourself.
publishedAt, updatedAtISO 8601 UTC.
canonicalUriThe post's canonical public web permalink. Link to it rather than assembling a URL.
protocolThe post's contentId and that chain's current headOpCid, carried on the card and on the full post.
viewer{ upvoted } for the granting user. Present only on a member projection, absent from every anonymous response.

protocol is absent, never null, until the content chain exists — briefly after publication — and on legacy or protocol-ineligible posts. See Protocol discovery and Verify a post.

Reading as the granting user

A credential carrying read:posts for this space — or a bare identity proof, to read your own — turns both read endpoints into the feed that user sees. Posts that are not publicly readable appear, including ones in private topics and behind a pass; a readable post comes back eligible with the full body and every attachment instead of stopping at the fold; and every card carries viewer.

A post the granting user still cannot read returns the same gated call to action anyone else gets, never a member-specific error. A credential that does not cover this space returns exactly the anonymous response — see Optional authentication.

Filters and ordering

All listing parameters are optional:

ParameterMeaning
formatshort-post or long-post
topicPublic topic ID (from topics[] or Topics)
sortnewest (default) or oldest
publishedAfterPublished strictly after this ISO 8601 timestamp
publishedBeforePublished strictly before this ISO 8601 timestamp

Filters compose and totalCount reflects the filtered set. topic matches within whatever the caller can already see, and an unknown, unreachable, or foreign topic ID returns an empty page rather than revealing whether the topic exists. Hold every filter and sort constant while walking cursors; cursors do not encode them, and after fetches the next page in the selected sort order.

Fetching a single post

GET /v1/spaces/{space}/posts/{postId} addresses a post by id, not slug. A valid post id under a space it does not belong to returns 404 Not Found, the same as a missing post.

The 200 body is a discriminated union on state: the full post, or a slim call to action describing what would grant access.

Eligible: the full post

{
"state": "eligible",
"post": {
"id": "post_ze2kh2d47tzerkhet8348c",
"slug": "dfos-beyond-dfos-sign-in-domains-and-your-own-keys",
"format": "long-post",
"title": "DFOS beyond DFOS: sign-in, domains, and your own keys",
"displayTitle": "DFOS beyond DFOS: sign-in, domains, and your own keys",
"excerpt": "Platforms today make your identity real by hosting it. Your @ is real because it appears on their domain. Your work is real because their page says it is...",
"body": "Platforms today make your identity real by hosting it. Your @ is real because it appears on their domain...",
"attachments": [],
"bodyMedia": [],
"author": {
"did": "did:dfos:z8zt7ecn9h8n782kae3k796crva2c73",
"displayName": "Brandon",
"username": "bvalosek"
},
"topics": [{ "id": "topic_6c2efd472dvt8rf9k4ftcc", "name": "DFOS Blog" }],
"canonicalUri": "https://home.dfos.com/post/dfos-beyond-dfos-sign-in-domains-and-your-own-keys-ze2kh2d47tzerkhet8348c",
"upvoteCount": 14,
"commentCount": 6,
"publishedAt": "2026-09-04T18:05:33.531Z",
"updatedAt": "2026-09-04T18:06:01.020Z"
},
"space": {
"displayName": "DFOS",
"description": "The official DFOS of DFOS",
"avatarUrl": "https://dfos.imgix.net/media/public/8e87h8c37f2vz3v6h7h3at-frame-143726546.png",
"domain": "home",
"joinMode": "open",
"subscribeEnabled": true,
"memberCountSummary": "thousands of members"
}
}

On top of the card's fields the full post adds the markdown body; attachments and bodyMedia, the media referenced inline in the body, both always arrays and empty when the post has none; the post's topics; and canonicalUri, the space's public host plus /post/{slug}-{id}, which accounts for a custom domain. It also carries space, the same compact context the gated variant carries, so one request renders a whole post page with its join / subscribe prompt.

When the body contains bare URLs, the full post may include links, in their order of first appearance. Each item carries the original url, nullable title, description, and siteName, and a zero-based sortOrder; imageUrl and faviconUrl are permanent public CDN URLs when DFOS has cached a preview image or favicon. Unresolved links are still included with null metadata, and the array is absent when the post has no links. Link cards are the same for every viewer.

Internal entity labels

When the served body contains DFOS app links to publicly visible posts or spaces, or to published space-visible events in the same space whose public profile is enabled, the full post may include entityLinks. Each item carries the normalized absolute url, its resolved label, and a kind of post, space, or event — an open enum; render unknown kinds as ordinary links.

Labels are the same for every viewer and derived only from the body returned in this response, so a folded post omits links below the fold. The array is absent when no internal links resolve.

Media fields

Every media object (cover, attachments[], bodyMedia[]) carries id, filename, contentType, and a resolved url, plus optional rendering hints (width, height, blurHash, alt, durationMs, posterUrl, playbackUrl, contentLength, waveformPeaks). Body markdown references media by id through attachment://{id} tokens, so an inline reference joins to its bodyMedia entry.

A public image's url is a permanent, unsigned CDN URL with no urlExpiresAt. Private media (audio, video, files) returns url — and playbackUrl when present — as a time-limited signed URL inline, with urlExpiresAt marking when it stops working. Never persist a signed URL: re-fetch the post. See Compatibility → Media URLs.

Folded posts. With a fold marker, only the above-fold teaser is returned and folded is true; the field is absent otherwise, so folded === true is your only signal the body is truncated. Media and link cards below the fold are omitted, and below-fold content cannot be retrieved anonymously — link out to canonicalUri. See Content visibility.

Gated: the space call-to-action

When the post exists but the caller cannot read it, state is "gated": no content, only a slim space CTA and a reason.

{
"state": "gated",
"reason": "spaceMembers",
"space": {
"displayName": "DFOS",
"description": "The official DFOS of DFOS",
"avatarUrl": "https://dfos.imgix.net/media/public/8e87h8c37f2vz3v6h7h3at-frame-143726546.png",
"domain": "home",
"joinMode": "open",
"subscribeEnabled": true,
"memberCountSummary": "thousands of members"
}
}

reason is "signIn" (any signed-in visitor can read it), "spaceMembers" (joining the space grants access), or "restricted" (a finer gate — a pass, a group, a private topic tier — so joining alone is not guaranteed to grant access; send the visitor to the space rather than promising it will unlock). It is an open enum: treat an unrecognized value as "restricted" (see the compatibility contract).

Render a space header or join / subscribe prompt from the space block's domain, joinMode, and subscribeEnabled.

The cross-space feed

GET /v1/feed is the granting user's own post feed across every space the grant reaches — newest first, cursor-paginated, one flat stream, and the only route here with no space in its path. It is gated: a call with no Authorization header is a 401, not an empty page. It requires read:posts under a credential, or a bare identity proof to read your own.

Each item is the space listing's card plus a space block: space.id, the space's stable entity id; space.did, its protocol DID; space.name, its display name or null; and space.url, its canonical public web address. Both id and did are accepted back as {space} on every space-addressed route. On the credential-aware ones — posts, comments, and media — that includes a private space, when the credential covers it and the user is an active member; the anonymous routes (the space profile, pages, topics, events, products, releases) still need a public profile. A url for a private space is real but does not resolve anonymously.

A credential covering all the user's spaces feeds every space they are an active member of, including ones they join later; one naming particular spaces feeds exactly those, still only while the user is active in them. A space they have left, or where membership was deactivated, contributes nothing and is not reported. A caller in no reachable space gets an empty page. Pinned posts are not hoisted here.

Comments

GET /v1/spaces/{space}/posts/{postId}/comments returns a post's comment thread as the granting user sees it. It is gated, with no anonymous projection. Comment visibility inherits the post's, so read:posts carries comments; a bare identity proof reads your own.

Every refusal that is not an authentication failure is the same 404 as an unknown post: a grant that does not reach this space, a post in another space, a post the user cannot read, and a post that does not exist are one answer. On a private space the refusal for anyone who is not an active member — an anonymous caller included — is the space's own 404 instead, identical to a space that does not exist. The listing is flat and returns root comments by default.

FieldValue
id, postIdThe comment and the post it is on.
parentCommentIdAbsent on a root comment.
authorThe same author identity shape as a post.
bodyThe comment's markdown, or null for a deleted comment, or for one that carries only attachments.
attachmentsFiles on the comment, in order — the same media shape a post uses. Always present, empty when there are none, and never folded.
publishedAtWhen the comment was published.
activityAtThe value the listing is ordered by: on a root comment, the later of its own publication and its most recent reply; on a reply, its own publishedAt.
upvoteCount, replyCountreplyCount is always 0 on a reply.
viewer{ upvoted }. Always present; this route is always authenticated.

Sort merged pages by activityAt, not publishedAt: an active thread sorts ahead of an older one under newest. Pass parentCommentId to walk one comment's replies instead of the roots. sort accepts newest (default), oldest, or top.

Writing

Twelve routes write. They need a write:* scope on top of the read ones and all follow Conventions § Writing — read that first: the jti requirement and the 409 it produces apply to every one.

RouteScope
POST /v1/spaces/{space}/postswrite:posts
PATCH /v1/spaces/{space}/posts/{postId}write:posts
DELETE /v1/spaces/{space}/posts/{postId}write:posts
POST /v1/spaces/{space}/posts/{postId}/commentswrite:comments
PATCH /v1/spaces/{space}/comments/{commentId}write:comments
DELETE /v1/spaces/{space}/comments/{commentId}write:comments
PUT / DELETE /v1/spaces/{space}/posts/{postId}/upvotewrite:upvotes
PUT / DELETE /v1/spaces/{space}/comments/{commentId}/upvotewrite:upvotes
POST /v1/spaces/{space}/mediawrite:posts or write:comments
GET /v1/spaces/{space}/media/{mediaId}write:posts or write:comments

The last two are Media — minting an upload and checking whether it landed. The POST is signed like a write, with a jti; the GET is signed like a read, without one.

Writing a post

POST /v1/spaces/{space}/posts takes { topic, body, title?, attachments? }. topic is a topic ID; this API never picks a room for you. GET /v1/spaces/{space}/topics enumerates world-readable topics only, so it supplies ids for public spaces. For a member or private topic, take the id from post.topics[] on a credentialed read — no route enumerates the topics a member can write to. A topic the granting user cannot post in is a 404. A title makes the post a long-post, and without one it is a short-post. The response is 201 Created with the full post at the author's own fold.

attachments is an ordered array of media ids from POST /v1/spaces/{space}/media — at most 10, and at most 4 on a short post. A long post can also place an attached image inside its body with an attachment://{mediaId} token; short posts refuse inline images and carry them as attachments instead.

No cover, no announce or broadcast, no backdating, no view-access override.

Editing and deleting

PATCH takes at least one of title, body, topic, or attachments; anything you omit is left alone, and an edit naming nothing is a 400. attachments replaces the whole set, and [] clears it. topic moves the post to another topic in the same space. DELETE answers { "deleted": true }.

Both reach your own posts only. A post the granting user did not write is 403, even when they are an admin of the space.

Commenting

POST /v1/spaces/{space}/posts/{postId}/comments takes { body?, attachments?, parentCommentId? }. Send a body, attachments, or both — a comment with neither is a 400, and inline images in the body are refused (attach them instead). Pass parentCommentId to reply to an existing comment; threads are one level deep, so a reply to a reply attaches to the same root. The response is 201 Created with the comment in the thread listing's shape, minus activityAt. A post the granting user cannot read gives the same 404 the read routes give.

PATCH on a comment takes body, attachments, or both, each a full replacement; attachments: [] clears the set.

Upvotes

PUT adds the granting user's upvote and DELETE removes it, on a post or a comment. Both are idempotent, and both report the state after the call:

{ "upvoted": true, "upvoteCount": 15 }

PUT always answers upvoted: true and DELETE always upvoted: false, whether or not that call changed anything, so set your toggle from the response. An upvote writes the user's name onto a signal other members can see.

Author identity

A post's author, on both the listing and the full post, can be null. When present it always carries the author's protocol did, plus displayName, username, and avatarUrl when set. The did is the stable identifier; username is a mutable alias. Pass either to GET /v1/users/{user} for the author's full public profile.


See the interactive reference for the exact schema of every field, and the guide to showing posts on your own site for an end-to-end recipe.