Skip to main content

Posts

Public posts live under a space. Two endpoints cover them:

  • GET /v1/spaces/{space}/posts — list a space's public posts.
  • GET /v1/spaces/{space}/posts/{postId} — fetch one post.

Only posts that a space and author have made publicly readable appear here. What "public" means, and how folding works, is covered in Content visibility.

Listing a space's posts

GET /v1/spaces/{space}/posts returns posts newest first, cursor-paginated (see Conventions). Each item is a compact card, not the full post:

  • id, slug, title (null for an untitled post).
  • format"short-post" or "long-post". Treat it as an open enum: handle an unrecognized value as a generic post.
  • displayTitle — a ready-to-render heading. It is the title when the post has one, otherwise a short excerpt of the above-fold body, otherwise null. Use it when you need a label for an untitled post without deriving one yourself; it is never derived from below-fold content.
  • excerpt — a truncated, markdown-stripped preview of the body.
  • author — the post's author identity, or null.
  • cover — a cover image, when the post has one (see Media fields).
  • upvoteCount, commentCount.
  • isPinned — whether the space has pinned this post. The feed is ordered purely by recency and does not move pinned posts to the top for you. Use this flag to surface pinned posts yourself if you want to.
  • publishedAt — ISO 8601 UTC.
  • canonicalUri — the post's canonical public web permalink (the same value the full post carries). Link to it rather than assembling the URL yourself.

To read a post's body, fetch it directly — the listing never carries the body.

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. An unknown, non-public, or foreign topic ID returns an empty page rather than revealing whether the topic exists. Hold every filter and sort constant while walking pagination cursors; cursors do not encode them. after always fetches the next page in the selected sort order.

Fetching a single post

GET /v1/spaces/{space}/posts/{postId} addresses a post by its id (the id from the listing), not its slug. A post fetched through the wrong space — a valid post id under a space it does not belong to — returns 404 Not Found, the same as a missing post.

The 200 response is a discriminated union on state. This is the single most important shape on this surface: an anonymous caller either gets the full post, or gets a slim call-to-action explaining what would grant access — never a partial leak.

Eligible: the full post

When the post is anonymously readable, state is "eligible" and the content is under post:

{
"state": "eligible",
"post": {
"id": "post_4k8m2q7v",
"slug": "notes-from-the-wetlands",
"format": "long-post",
"title": "Notes from the wetlands",
"displayTitle": "Notes from the wetlands",
"excerpt": "The waterline rose overnight. Here is what we found...",
"body": "The waterline rose overnight. Here is what we found...",
"attachments": [],
"bodyMedia": [],
"author": {
"did": "did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc",
"displayName": "Mara Ortiz",
"username": "mara"
},
"topics": [{ "id": "topic_8m2q7v4k", "name": "Reports" }],
"canonicalUri": "https://field-notes.dfos.com/post/notes-from-the-wetlands-post_4k8m2q7v",
"upvoteCount": 12,
"commentCount": 3,
"publishedAt": "2026-07-19T18:00:00.000Z",
"updatedAt": "2026-07-19T18:12:00.000Z"
},
"space": {
"displayName": "Field Notes",
"description": "Dispatches from the wetlands.",
"avatarUrl": "https://cdn.dfos.com/…",
"domain": "field-notes",
"joinMode": "application",
"subscribeEnabled": true,
"memberCountSummary": "a few dozen members"
}
}

The full post carries the listing's excerpt — a truncated, markdown-stripped preview derived from the above-fold body only — and adds the markdown body; attachments and bodyMedia (media referenced inline in the body — both are always present as arrays, empty when the post has none); topics the post is assigned to; and canonicalUri, the post's canonical public web permalink — the space's public host plus the /post/{slug}-{id} path. Link to canonicalUri rather than assembling the URL yourself; it accounts for the space's custom domain.

The eligible variant also carries space, the same compact space context used by the gated variant. A single request therefore provides the post plus the space header and join/subscribe context needed to render a complete post page.

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

Link cards are viewer-independent. They never expose internal resolution state or viewer-scoped entity-card data.

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 an entityLinks array. Each item carries the normalized absolute url, its resolved label, and a kind whose current values are post, space, or event. Treat kind as an open enum and render unknown values as ordinary links.

These labels are viewer-independent and derived only from the body returned in this response. When a post is folded, links below the fold are not included. The array is absent when no internal links resolve.

Media fields

Every media object (cover, attachments[], bodyMedia[]) carries an id, filename, contentType, and a resolved url, plus optional rendering hints (width, height, blurHash, alt, durationMs, posterUrl, playbackUrl, contentLength, waveformPeaks).

There are two URL classes, and each object tells you which it is:

  • Public imagesurl is a permanent, unsigned CDN URL, with no urlExpiresAt.
  • Private media (audio, video, files) — url (and playbackUrl, when present) is a time-limited signed URL, returned inline, with a urlExpiresAt timestamp marking when it stops working.

Never persist a signed URL. When urlExpiresAt is present, the URL is temporary — re-fetch the post to get a fresh one. Body media references the media objects by id (the body markdown uses attachment://{id} tokens), so you can join an inline reference to its bodyMedia entry. See Compatibility → Media URLs.

Folded posts. When a post has a fold marker, only the above-fold teaser is returned and folded is true. The folded field is absent when the post has no fold — so folded === true is your only signal that the body is truncated. Media and link cards that appear only below the fold are omitted too. There is no way to retrieve below-fold content anonymously; link out to canonicalUri for the full post. See Content visibility for how folding is defined.

Gated: the space call-to-action

When the post exists in the space but an anonymous caller cannot read it, state is "gated". No content is returned — only a slim space CTA and a reason telling you what would grant access:

{
"state": "gated",
"reason": "spaceMembers",
"space": {
"displayName": "Field Notes",
"description": "Dispatches from the wetlands.",
"avatarUrl": "https://cdn.dfos.com/…",
"domain": "field-notes",
"joinMode": "application",
"subscribeEnabled": true,
"memberCountSummary": "a few dozen members"
}
}

reason is one of:

  • "signIn" — open to any signed-in visitor; signing in reveals it.
  • "spaceMembers" — plain space membership is enough, so joining the space grants access.
  • "restricted" — a finer gate applies (a pass, a group, a private topic tier, and so on); joining the space alone is not guaranteed to grant access. Send the visitor to the space to see how to gain access rather than promising that joining will unlock it.

reason is an open enum: finer reason values may be added later, so treat any value you don't recognize as "restricted" (see the compatibility contract).

Use the space context to render a space header or join / subscribe prompt — its domain, joinMode, and subscribeEnabled tell you how joining works for this space. The same shape is present on the eligible variant.

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, and — when set — displayName, username, and avatarUrl. The did is the stable identifier; the username is a mutable alias. To fetch the author's full public profile, pass the did (or username) to GET /v1/users/{user}.


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.