POST /api/v1/media/assets upload full token, media:create
GET /api/v1/media/assets/:id/raw stream the bytes any token, media:readUpload exists so a full token can fill a media field, which stores asset ids.
Uploading
Multipart, bytes in the file part. folderId and alt are query
parameters.
curl -X POST 'https://cms.example.com/api/v1/media/assets?alt=A%20lighthouse%20at%20dusk' \
-H 'Authorization: Bearer orthacms_…' \
-F 'file=@hero.jpg' import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append(
'file',
new Blob([await readFile('hero.jpg')], { type: 'image/jpeg' }),
'hero.jpg'
);
// Do not set Content-Type yourself — fetch adds the multipart boundary.
const response = await fetch(
'https://cms.example.com/api/v1/media/assets?alt=' +
encodeURIComponent('A lighthouse at dusk'),
{
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN },
body: form
}
);
const asset = await response.json(); // asset.id goes in a media field | Parameter | Where | Effect |
|---|---|---|
file | multipart part | The bytes. Required (400 without). |
folderId | query | Library folder. Omit for the workspace root. |
alt | query | The asset’s default text alternative. |
201, with the stored asset:
{
"id": "3f7a…",
"name": "hero.jpg",
"folderId": null,
"kind": "image",
"mimeType": "image/jpeg",
"size": 482113,
"url": "/api/media/assets/3f7a…/raw",
"variants": ["thumb", "preview"],
"width": 2400,
"height": 1600,
"duration": null,
"tags": [],
"alt": "A lighthouse at dusk",
"tracks": [],
"uploadedBy": "7e4a…",
"createdAt": "2026-09-30T10:00:00.000Z",
"updatedAt": "2026-09-30T10:00:00.000Z"
}urlhere is the admin’s session route. With a token, fetch/api/v1/media/assets/:id/rawinstead.- Raster images get their
thumbandpreviewderivatives on upload. - The upload is attributed to whoever minted the token. A token whose
creator is gone gets a 400 (
This token has no creator on record and cannot upload.).
Attaching it to an entry
A media field takes the id, bare or with a per-usage alt. A multiple field
takes an array.
curl -X POST 'https://cms.example.com/api/v1/content/article' \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{"values":{
"title":"Lighthouses",
"coverImage":{"id":"3f7a…","alt":"A lighthouse at dusk"},
"gallery":["8b2c…",{"id":"d91e…","decorative":true}]
}}' await fetch('https://cms.example.com/api/v1/content/article/' + id, {
method: 'PATCH',
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify({
values: { coverImage: { id: asset.id, alt: 'A lighthouse at dusk' } }
})
}); # GraphQL media inputs take asset ids only.
# Set a per-usage alt or decorative flag over REST.
mutation {
updateArticle(id: "9c4b…", input: { coverImage: "3f7a…" }) {
id
coverImage { id alt }
}
} An asset id the workspace does not own, or one the field’s accept rule
excludes, is a 422.
Pass alt on an unattended import
A required media field needs each asset to carry its own alt on the entry
value, or "decorative": true, before the entry can publish. The asset’s
default alt is not enough — write { "id": …, "alt": … } into the field.
Serving bytes
curl -L 'https://cms.example.com/api/v1/media/assets/3f7a…/raw?variant=thumb' \
-H 'Authorization: Bearer orthacms_…' -o thumb.jpg- Any token can fetch — both scopes carry
media:read. ?variant=thumbor?variant=previewserves a derivative when it exists, the original otherwise.- An asset in another workspace is the same 404 as a missing one.
- With
MEDIA_DIRECT_SERVE=signed-url, the answer is a 302 to a short-lived storage URL. Follow redirects (curl -L). See media storage.
These URLs are not public
The route needs the bearer token, so it cannot go in a public <img src>. Two
shapes work: proxy the bytes through your server, or copy assets to a
CDN at build time and rewrite URLs.
A minimal proxy (Express shown; any server route works the same way):
// GET /img/:id → streams the asset without exposing the token
app.get('/img/:id', async (req, res) => {
const variant = req.query.variant === 'thumb' ? '?variant=thumb' : '';
const upstream = await fetch(
process.env.ORTHACMS_URL + '/api/v1/media/assets/' +
encodeURIComponent(req.params.id) + '/raw' + variant,
{ headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN } }
); // fetch follows a signed-url redirect by itself
if (!upstream.ok) return res.sendStatus(upstream.status);
res.set('Content-Type', upstream.headers.get('content-type'));
res.set('Cache-Control', 'public, max-age=3600');
res.send(Buffer.from(await upstream.arrayBuffer()));
});The raw route sends no Cache-Control of its own; set yours on the proxy.
What a token cannot do
Library curation is session-only. Neither scope carries media:update or
media:delete.
| Operation | Public API | Admin API |
|---|---|---|
| Upload an asset | Yes | Yes |
| Read an asset’s bytes | Yes | Yes |
| Rename, set default alt, move | No | Yes |
| Create or delete folders | No | Yes |
| Delete an asset | No | Yes |
Upload limits
- Capped by
MEDIA_MAX_UPLOAD_BYTES(default 50 MB), same as the admin. - Over the cap: 413
File exceeds the maximum upload size.— rejected, not truncated.
# .env — 100 MB
MEDIA_MAX_UPLOAD_BYTES=104857600What to read next
- Expanding relations and media — how a content read hands out these URLs.
- Media storage — backends, direct serve, the upload cap.