Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

The media API

Media plugin @orthacms/media-server@orthacms/media-admin

Upload an asset, attach it to an entry, get its bytes back — and why those URLs are not public.

Documents 0.13.1 Updated Edit this page Report a problem

On this page
POST /api/v1/media/assets            upload            full token, media:create
GET  /api/v1/media/assets/:id/raw    stream the bytes  any token, media:read

Upload 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.

Example language
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
ParameterWhereEffect
filemultipart partThe bytes. Required (400 without).
folderIdqueryLibrary folder. Omit for the workspace root.
altqueryThe 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"
}
  • url here is the admin’s session route. With a token, fetch /api/v1/media/assets/:id/raw instead.
  • Raster images get their thumb and preview derivatives 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.

Example language
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=thumb or ?variant=preview serves 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.

OperationPublic APIAdmin API
Upload an assetYesYes
Read an asset’s bytesYesYes
Rename, set default alt, moveNoYes
Create or delete foldersNoYes
Delete an assetNoYes

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=104857600