Post a comment as a user
Writing is a gated read with two extra obligations: the user grants a write:*
scope on the spaces you will write in, and every request carries a per-request
jti so the same signed request cannot be executed twice. This recipe does the
smallest useful write, a comment on a post, end to end. It assumes you can
already make a gated read — if not, start with
Sign in with DFOS.
1. Ask for write:comments, and name the spaces
https://app.dfos.com/authorize
?challenge=<base64url challenge>
&redirect_uri=https://yourapp.example/callback
&scope=read:posts write:comments
&spaces=9ctvrdn9vedda7efetrhcdakfh4cr2k
&client_did=did:dfos:…
Ask for the spaces you need, not for all of them. spaces=<id>,<id> settles
the set at the request: the consent screen lists exactly those spaces and offers
no picker. Omitting the parameter hands the choice to the user — a picker over
their own spaces with nothing pre-ticked, plus an explicit all-spaces option.
spaces=all is what asks for every space they belong to, including ones they
join later. The rule lives in
Scopes and credentials.
A write scope is its own line on the consent screen. write:comments says
"write, edit, and delete your own comments"; it never reaches anyone else's
comments and never moderates. write:posts and write:upvotes are separate
grants. Ask for the narrowest one that does your job.
2. Sign every request, with a jti
createApiAuthFetch signs exactly the Request it is handed — method, target,
and body octets as composed — and its default jti: 'writes' attaches a fresh
identifier to every method except GET, HEAD, and OPTIONS:
import { createApiAuthFetch } from '@metalabel/dfos-client/api-auth';
const signedFetch = createApiAuthFetch({
credential, // the credential from the callback fragment
kid, // your app's signing key DID URL
sign, // raw Ed25519 signer
});
If you sign requests yourself with signApiRequest, mint the identifier per
request with generateJti() and pass it as jti. Never reuse one, and never
derive it from the request's content — two identical comments a minute apart are
two requests, and a content-derived identifier would make the second look like a
replay of the first.
3. Write the comment
const response = await signedFetch(
'https://api.dfos.com/v1/spaces/home/posts/post_ze2kh2d47tzerkhet8348c/comments',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
body: 'The exit-key framing is the part that clicks for me: the door exists before anyone needs it.',
}),
},
);
const comment = await response.json();
201 Created, and the body is the comment as the granting user now sees it:
{
"id": "post_9rze4tk2vdc7fa38nhe6c2",
"postId": "post_ze2kh2d47tzerkhet8348c",
"author": { "did": "did:dfos:z8zt7ecn9h8n782kae3k796crva2c73", "username": "bvalosek" },
"body": "The exit-key framing is the part that clicks for me: the door exists before anyone needs it.",
"publishedAt": "2026-09-04T19:12:40.000Z",
"upvoteCount": 0,
"replyCount": 0,
"viewer": { "upvoted": false }
}
Pass parentCommentId to reply to an existing comment instead of the post.
The content-type header is required and must be application/json — this API
serves exactly one body media type. Do not compress the body and do not use a
method override; both are refused. See
Conventions § Writing.
3b. Optional: attach a file
A comment can carry media. Upload it first, then name it — the whole flow is in Media, and it is three calls:
// Mint. `size` must be the file's exact byte length.
const mint = await signedFetch('https://api.dfos.com/v1/spaces/home/media', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
filename: 'studio-floor.jpg',
contentType: 'image/jpeg',
size: bytes.byteLength,
}),
}).then((r) => r.json());
// Send the bytes yourself, with BOTH returned headers exactly as given.
await fetch(mint.upload.url, { method: 'PUT', headers: mint.upload.headers, body: bytes });
// Poll — the finalize is an S3 event, not a response to your PUT. This is a
// plain GET, so `signedFetch` signs it without a `jti` and the route wants none.
const status = `https://api.dfos.com/v1/spaces/home/media/${mint.media.id}`;
while (!(await signedFetch(status).then((r) => r.json())).uploaded) await wait(1000);
Then send the comment with attachments instead of (or alongside) body:
body: JSON.stringify({ attachments: [mint.media.id] });
A comment needs a body, attachments, or both. Inline images in the body are
refused — a picture in a comment is an attachment. The comment reads back with
its files on an attachments array, so an attachment-only comment has
body: null and the media beside it.
4. Handle the 409
if (response.status === 409) {
// The request already happened. The FIRST attempt may have succeeded.
const thread = await signedFetch(
'https://api.dfos.com/v1/spaces/home/posts/post_ze2kh2d47tzerkhet8348c/comments',
);
// …look for your comment, and only write again if it is genuinely absent.
}
A 409 means DFOS has already seen this request's jti inside the proof's
freshness window — your request already arrived, and the first attempt may have
succeeded. Read the current state and reconcile.
Re-sending the same signed request answers 409 for as long as the window
lasts. Re-signing the same payload mints a new jti and will create a second
comment: the cache bounds replay, not repetition. So a retry belongs after the
re-read above, never inside a transport-level retry loop.
5. Know what you cannot do
- Only the granting user's own comments. Editing or deleting somebody
else's is
403, even if the user is an admin of the space. This API does not moderate. - Nothing that speaks for the space. Announcing, broadcasting, pinning, backdating, and view-access overrides are not on these requests at all.
- A post the user cannot read is a
404, the same404as a post that does not exist, so a failed write never tells you what exists.
The full model is in Content visibility, and the route reference is Posts § Writing.