Conventions
The behavior shared across every DFOS API resource, assumed by every resource page. What stays stable versus what can change is the compatibility contract.
Authentication
There are three kinds of route.
| Kind | Routes | What it takes |
|---|---|---|
| Anonymous | Most of the surface. | A plain GET. No key, no token, no account. |
| Gated | /v1/profile, the membership routes, /v1/credential, /v1/feed, the post comments route, and the writes. | A credential carrying the action the route asks for, plus a proof. |
| Optional-auth | GET /v1/spaces/{space}/posts and GET /v1/spaces/{space}/posts/{postId}. | Nothing, or a credential covering that space with read:posts to get more back. |
GET /v1/credential needs a valid credential but no particular action — a
credential may always describe itself. Optional-auth routes list an empty
security requirement first in the OpenAPI document. Getting a credential is the
Sign in with DFOS flow, wired in
Set up § 6.
A gated request carries two headers, and neither works without the other:
Authorization: DFOS <request-proof>
X-Credential: <credential>
The credential is the durable grant: it names what your app may do and stays
good until revoked. The request proof is a fresh signature over this one
request — method, host, path, and a hash of the body — good only for that exact
request and only briefly. Other request headers are not covered by the proof. The
scheme token is DFOS, not Bearer; neither artifact is a bearer token.
Background: Why sign every request.
Optional authentication
A grant only ever adds. There are four outcomes:
| What you send | What you get |
|---|---|
No Authorization header | The anonymous projection. |
| A valid proof whose grant covers this space | The projection the granting user sees. |
| A valid proof whose grant does not cover this space | Exactly the anonymous projection, byte for byte. |
| A proof that is malformed, stale, revoked, or forged | 401 or 403, never a quiet downgrade. |
Read your own coverage from GET /v1/credential.
Reading your own data needs no credential
Every route on this API that accepts a credential except
GET /v1/credential also accepts a bare identity proof —
the reads and the writes alike. Send it with no X-Credential:
Authorization: DFOS <identity proof>
An identity proof is signed exactly like a request proof but names no grant. It
identifies the DID whose data comes back, with no space restriction, and the
OpenAPI document marks those operations with the identity alternative. Sending
both an identity proof and X-Credential is 401. An app acting for a user
presents the delegated pair, which is all GET /v1/credential accepts. Byte
contract: DFOS API-AUTH.
Freshness
A proof is accepted for about a minute after it is signed, with about a minute of clock-skew allowance on either side. Sign at the moment you send, and keep your clock in sync. That window will never be widened past five minutes.
When a gated request is refused
| Status | Meaning |
|---|---|
401 | The proof was refused: missing, malformed, stale, or it did not verify. Sign a fresh proof over this exact method, host, path, and body. |
403 | The proof held and the grant did not: expired, revoked, not DFOS-issued, or its permissions do not cover this action on this host. You need a new grant. On an optional-auth route, "does not cover this space" returns the anonymous projection instead. |
404 | On the gated comments route, also "your grant does not reach the space this post is in" — identical to a post that does not exist. And, on the posts, comments, and media routes, a private space to anyone who is not an active member presenting a covering credential — every other space-addressed route still needs a public profile. |
503 | Verification could not be completed. Retry with backoff; see Rate limits and caching for the other condition returning this status. |
Branch on the status code and the typed error body. The 401 body is always
there; a WWW-Authenticate: DFOS header is best-effort and infrastructure
between your client and this API can rename or drop it.
Writing
Writes need everything a gated read needs, plus a jti and one extra refusal.
- Every write carries a
jti, a per-request identifier inside the signed proof. Mint a fresh one per request —generateJti()in@metalabel/dfos-clientdoes it, andcreateApiAuthFetchattaches one to every request exceptGET,HEAD, andOPTIONS. A proof with nojtiis401. 409 Conflict: thisjtiwas already accepted, so the first attempt may have succeeded. Re-read state before retrying, and retry with a newjti.- Send
application/json, uncompressed, with the method you signed. Another media type is415, as is aContent-Encodingother thanidentity. A?_method=query is400, and so is a body that declares JSON and does not parse as JSON. AnX-HTTP-Method-Overrideheader is dropped at the edge and ignored; the proof binds the method you signed, so it cannot change anything. - You can only write your own content.
write:postsandwrite:commentsreach the granting user's own posts and comments; editing or deleting another member's content is403even for a space admin. - The fields that would speak for a space are absent — no announce or
broadcast, pin, backdate, view-access override, or moderation. Request bodies
are closed, so naming a field this API does not serve is a
400. - Files are a two-step.
POST /v1/spaces/{space}/mediamints a presigned upload you perform yourself; the returned id then goes inattachmentson a post or comment write. - A bare identity proof writes, exactly as it reads — no credential, no
consent screen — with one refusal of its own: a key whose only role is
controlleris403. An exit key is for recovering an identity, not for authoring as one. - Creates answer
201, every other write answers200. Edits return the object, deletes return{ "deleted": true }, and the upvote toggles return the state after the call.
Which scope opens which route:
| Scope | Routes |
|---|---|
write:posts | POST /v1/spaces/{space}/posts, PATCH / DELETE on /v1/spaces/{space}/posts/{postId}, and the two media routes |
write:comments | POST /v1/spaces/{space}/posts/{postId}/comments, PATCH / DELETE on /v1/spaces/{space}/comments/{commentId}, and the two media routes |
write:upvotes | PUT / DELETE on …/posts/{postId}/upvote and …/comments/{commentId}/upvote |
The two media routes — including the GET — are opened by either write
scope, because an upload is the first step of a post or a comment rather than an
authority of its own. write:upvotes alone does not open them.
The scope a route asks for and the jti it requires are separate questions.
GET /v1/spaces/{space}/media/{mediaId} is opened by a write scope and signed
like a read: no jti, no 409. Every route that actually changes something
requires one. A signed client using createApiAuthFetch gets this right by
default — it attaches a jti to every method except GET, HEAD, and
OPTIONS.
Proof construction is specified in
DFOS API-AUTH and implemented by
@metalabel/dfos-client. A runnable gated call is
Set up § 7;
refusals are covered in
Troubleshooting.
Pagination
Cursor pagination on every list endpoint: GET /v1/spaces, /v1/spaces/{space}/pages,
/v1/spaces/{space}/posts, /v1/spaces/{space}/topics, /v1/spaces/{space}/events,
/v1/events/upcoming, /v1/spaces/{space}/products, /v1/products,
/v1/spaces/{space}/releases, /v1/releases, /v1/users,
/v1/users/{user}/spaces, /v1/memberships, /v1/group-memberships, /v1/feed,
and /v1/spaces/{space}/posts/{postId}/comments.
| Parameter | Meaning |
|---|---|
limit | Page size. Default 20, minimum 1, maximum 100. |
after | The response's nextCursor. Fetches the next page. |
before | The response's previousCursor. Fetches the previous page. |
Do not send after and before together. A null cursor means there is no page
in that direction. Hold any filters and sort order constant for the whole walk;
cursors do not encode them, and changing them mid-walk can skip or repeat items.
curl 'https://api.dfos.com/v1/spaces?limit=20&after=eyJpZCI6InNwYWNlX3ZuemZrN2h0aDl2YWRjM2RhYWhkNDgifQ'
totalCount is the number of matching items when the endpoint computes it. It is
optional — it may be null or absent, so treat it as a hint.
Cursors are opaque. Store and return them unchanged; never parse, decode, or construct one. Their encoding can change (see the compatibility contract).
Identifiers
Every resource is addressable by a stable identifier and, where it exists, by a mutable alias:
| Resource | Stable (store these) | Mutable alias (display / links only) |
|---|---|---|
| Space | entity id (space_…), protocol DID | subdomain (domain) |
| User | entity id (identity_…), protocol DID | handle (username) |
| Post | entity id (post_…) | slug |
| Comment | entity id (post_…) | none |
| Page | id (page_…) | slug |
| Product | id (sprod_…) | slug |
| Release | id (rel_…) | slug |
A stable identifier points at the same entity for its lifetime. A mutable alias can change or be released, and a released alias can later resolve to a different entity. Never key your own storage on a subdomain, handle, or slug.
A comment id is a post_… id. A comment on this API is a post, so it is
minted and addressed in the post namespace — there is no comment_… id on this
wire. DFOS does use that prefix elsewhere for an unrelated kind of comment this
API does not serve, so a client routing on prefix should not expect one here.
DIDs on the wire
Every did in a response is a protocol DID: did:dfos: followed by 31
chain-derived characters, 40 total. It is the only DID form this API speaks.
Treat DIDs as opaque and resolve them against the relay from
GET /v1/protocol.
Errors and not-found
Errors are machine-readable — read the response body's error fields rather than
relying only on the HTTP status. 429 and 503 are covered under
Rate limits and caching.
A 404 Not Found does not say why: private, missing, and
unknown-identifier resources all return the same 404. The credentialed analogue
is the membership checks' collapsed 404, where "no such space" and "your user is
not a member of it" are the same answer — see
Membership routes. An authenticated member of a private space
is the one caller a space-addressed route discloses it to. The full model is in
Content visibility.
The credential-gated routes add a 401 (E_AUTHENTICATION_FAILED) and a 403
(E_UNAUTHORIZED); see
When a gated request is refused. The write
routes add a 409 Conflict; see Writing.
Stability
New fields, new enum values, and new endpoints can arrive without a version bump; existing fields and behavior are preserved once the API reaches general availability. Write clients that ignore unknown fields and tolerate unknown enum values — the full rules are in the compatibility contract.