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(nullfor 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 thetitlewhen the post has one, otherwise a short excerpt of the above-fold body, otherwisenull. 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, ornull.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:
| Parameter | Meaning |
|---|---|
format | short-post or long-post |
topic | Public topic ID (from topics[] or Topics) |
sort | newest (default) or oldest |
publishedAfter | Published strictly after this ISO 8601 timestamp |
publishedBefore | Published 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.
Link cards
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 images —
urlis a permanent, unsigned CDN URL, with nourlExpiresAt. - Private media (audio, video, files) —
url(andplaybackUrl, when present) is a time-limited signed URL, returned inline, with aurlExpiresAttimestamp 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.