Skip to main content

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 same 404 as 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.