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 upload URL
that you PUT the bytes to yourself. 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. 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 the upload URL. 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, usually by a few
seconds, and there is nothing to call to hurry it. uploaded is the only
signal. Until it flips, url and contentLength are absent.
If uploaded stays false long after a successful PUT, the bytes never
arrived 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:
| Kind | Served as | url |
|---|---|---|
| Images | A public URL | Permanent, unsigned. No urlExpiresAt. |
| Audio, video, and every other file type | A short-lived signed URL | Expires — urlExpiresAt says when. Never persist it. |
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: ",
"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
| Status | Meaning |
|---|---|
400 | size missing, over its kind's cap, or a body that fails validation. |
401 | The proof was missing, malformed, or stale — and on the mint only, carried no jti. The status check needs none. |
403 | On 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. |
404 | On 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. |
409 | On 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 attaches is removed 30 days after it was minted. 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.
The 30-day window cannot be shortened or extended, and there is no delete endpoint.
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.