Skip to main content

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.

KindRoutesWhat it takes
AnonymousMost 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-authGET /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 sendWhat you get
No Authorization headerThe anonymous projection.
A valid proof whose grant covers this spaceThe projection the granting user sees.
A valid proof whose grant does not cover this spaceExactly the anonymous projection, byte for byte.
A proof that is malformed, stale, revoked, or forged401 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

StatusMeaning
401The proof was refused: missing, malformed, stale, or it did not verify. Sign a fresh proof over this exact method, host, path, and body.
403The 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.
404On 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.
503Verification 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-client does it, and createApiAuthFetch attaches one to every request except GET, HEAD, and OPTIONS. A proof with no jti is 401.
  • 409 Conflict: this jti was already accepted, so the first attempt may have succeeded. Re-read state before retrying, and retry with a new jti.
  • Send application/json, uncompressed, with the method you signed. Another media type is 415, as is a Content-Encoding other than identity. A ?_method= query is 400, and so is a body that declares JSON and does not parse as JSON. An X-HTTP-Method-Override header 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:posts and write:comments reach the granting user's own posts and comments; editing or deleting another member's content is 403 even 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}/media mints a presigned upload you perform yourself; the returned id then goes in attachments on 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 controller is 403. An exit key is for recovering an identity, not for authoring as one.
  • Creates answer 201, every other write answers 200. Edits return the object, deletes return { "deleted": true }, and the upvote toggles return the state after the call.

Which scope opens which route:

ScopeRoutes
write:postsPOST /v1/spaces/{space}/posts, PATCH / DELETE on /v1/spaces/{space}/posts/{postId}, and the two media routes
write:commentsPOST /v1/spaces/{space}/posts/{postId}/comments, PATCH / DELETE on /v1/spaces/{space}/comments/{commentId}, and the two media routes
write:upvotesPUT / 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.

ParameterMeaning
limitPage size. Default 20, minimum 1, maximum 100.
afterThe response's nextCursor. Fetches the next page.
beforeThe 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:

ResourceStable (store these)Mutable alias (display / links only)
Spaceentity id (space_…), protocol DIDsubdomain (domain)
Userentity id (identity_…), protocol DIDhandle (username)
Postentity id (post_…)slug
Commententity id (post_…)none
Pageid (page_…)slug
Productid (sprod_…)slug
Releaseid (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.