Skip to main content

Media

Two endpoints put a file on DFOS:

  • POST /v1/spaces/{space}/media — mint an upload.
  • GET /v1/spaces/{space}/media/{mediaId} — check whether it finished.

The bytes never go through this API. The mint hands back a presigned S3 PUT that you perform yourself, and S3 tells DFOS when it lands. Both endpoints are gated by write:posts or write:comments — an upload is the first step of a post or a comment, not an authority of its own.

The mint is signed like a write, with a jti; the status check is signed like a read, without one. That is the ordinary rule for the method, not a special case: createApiAuthFetch attaches a jti to everything except GET, HEAD, and OPTIONS, so a normal signed client polls correctly with no extra configuration. See Conventions § Writing.

Four steps

1. Mint

curl -X POST 'https://api.dfos.com/v1/spaces/home/media' \
-H 'Authorization: DFOS <request-proof>' \
-H 'X-Credential: <credential>' \
-H 'Content-Type: application/json' \
--data '{
"filename": "studio-floor.jpg",
"contentType": "image/jpeg",
"size": 812446
}'

201 Created:

{
"media": {
"id": "media_2t9crk4hf7vz3ea8dn6r2c",
"filename": "studio-floor.jpg",
"contentType": "image/jpeg",
"uploaded": false
},
"upload": {
"url": "https://dfos-media.s3.us-east-1.amazonaws.com/media/public/2t9crk4hf7vz3ea8dn6r2c-studio-floor.jpg?X-Amz-Algorithm=…",
"method": "PUT",
"headers": {
"Content-Type": "image/jpeg",
"Content-Disposition": "inline; filename=\"studio-floor.jpg\""
},
"expiresAt": "2026-09-08T17:20:00.000Z"
}
}

size is required and must be the file's exact byte length: it is signed into the PUT as Content-Length, so a body of any other length is rejected by S3. Caps are per kind — images 10MB, audio, video, and everything else 4GB — and a size over the cap for its contentType is a 400.

2. Send the bytes

curl -X PUT --upload-file studio-floor.jpg \
-H 'Content-Type: image/jpeg' \
-H 'Content-Disposition: inline; filename="studio-floor.jpg"' \
'https://dfos-media.s3.us-east-1.amazonaws.com/media/public/…'

Send both headers exactly as returned. They are inside the signature; altering, reordering the case of, or omitting either gives you 403 SignatureDoesNotMatch from S3. Send no Authorization header — the URL is the credential. The signature dies at expiresAt (one hour); after that, mint again. There is no way to extend one.

3. Poll until it finalized

curl 'https://api.dfos.com/v1/spaces/home/media/media_2t9crk4hf7vz3ea8dn6r2c' \
-H 'Authorization: DFOS <request-proof>' \
-H 'X-Credential: <credential>'
{
"id": "media_2t9crk4hf7vz3ea8dn6r2c",
"filename": "studio-floor.jpg",
"url": "https://dfos.imgix.net/media/public/2t9crk4hf7vz3ea8dn6r2c-studio-floor.jpg",
"contentType": "image/jpeg",
"contentLength": 812446,
"uploaded": true
}

A successful PUT returns before DFOS has recorded it: S3 notifies DFOS asynchronously, usually within seconds, and there is nothing to call to hurry it. uploaded is the only signal. Until it flips, url and contentLength are absent — a pending row has no bytes, so it has no address and no measured size.

If uploaded stays false long after a successful PUT, the bytes never reached S3 (an egress-restricted client) or the headers did not match.

4. Attach it

{
"topic": "topic_6c2efd472dvt8rf9k4ftcc",
"body": "From the studio.",
"attachments": ["media_2t9crk4hf7vz3ea8dn6r2c"]
}

attachments is an ordered array of media ids on post create and edit and on comment create and edit. Attaching before uploaded is true is allowed — the reference is valid either way — but the attachment will not render until the bytes land, which is what the poll is for.

Both posts and comments read the media back as an attachments array of the same shape, so a comment carrying only a file is legible: body is null and attachments holds it. Comment attachments are never folded — a caller who can read the thread gets its files in full.

Public and private

The contentType decides how the object is served, and you do not get a say:

KindServed asurl
ImagesA public CDN URLPermanent, unsigned. No urlExpiresAt.
Audio, video, and every other file typeA short-lived signed S3 URLExpires — urlExpiresAt says when. Never persist it.

That split is why a paid or gated file cannot leak: a private object has no address that works without a fresh signature. See Compatibility → Media URLs.

Inline images in a post body

A long post (one with a title) can place an attached image inside its markdown with an attachment:// token:

{
"topic": "topic_6c2efd472dvt8rf9k4ftcc",
"title": "From the studio",
"body": "Here is the floor: ![shot](attachment://media_2t9crk4hf7vz3ea8dn6r2c)",
"attachments": ["media_2t9crk4hf7vz3ea8dn6r2c"]
}

The token resolves to a bodyMedia entry on read, matched by id. Only media you uploaded resolves; a token to anything else is a 400.

Short posts and comments refuse inline images — carry them as attachments instead. Only a DFOS media id works; an external image URL is a 400.

Refusals

StatusMeaning
400size missing, over its kind's cap, or a body that fails validation.
401The proof was missing, malformed, or stale — and on the mint only, carried no jti. The status check needs none.
403On a space with a public profile: the user is not a member of it. On the mint, also: the grant carries neither write:posts nor write:comments here.
404On the status check: the media does not exist, you did not upload it, it has been reclaimed, or your grant does not cover this space. All four are the same answer. Also, on both routes: a private space you are not a member of.
409On the mint only. This jti was already accepted; re-read before retrying, with a new jti.

Attaching media you did not upload gives you the same 404 on the write, in the form Media not found: media_….

Reclamation

An upload nobody ever attached is garbage. DFOS reclaims one 30 days after it was minted — long enough to cover any draft, retry, or client crash. Once media is attached to a live post or comment it is kept for as long as that content exists, including while the content sits in the trash.

Nothing you can do shortens or extends that window, and there is no delete endpoint: removing the last reference is what makes a file collectable.


See the interactive reference for the exact schema of every field, and Post a comment as a user for the write flow this plugs into.