Features Apograph CMS on GitHub

The media API

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

Upload an asset, get its bytes back, and the reason those URLs are not public.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Two routes, deliberately:

POST /api/v1/media/assets            upload
GET  /api/v1/media/assets/:id/raw    stream the bytes

Upload exists because a full token can author content, and a media field stores asset ids the writer refuses unless the workspace owns them. Without an upload, a token could never populate a media field at all — so uploading is part of “write content”, not a separate product.

Uploading

Multipart, with the bytes as the file part.

Example language
curl -X POST 'https://cms.example.com/api/v1/media/assets?alt=A%20lighthouse%20at%20dusk' \
-H 'Authorization: Bearer apograph_…' \
-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'
);
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.APOGRAPH_TOKEN },
      body: form
  }
);
const asset = await response.json(); // asset.id goes in a media field
ParameterEffect
fileThe multipart file part. Required.
folderIdWhich library folder to upload into. Omit for the workspace root.
altA text alternative.

Returns the stored asset. Its id is what a content type’s media field takes:

curl -X POST 'https://cms.example.com/api/v1/content/article' \
  -H 'Authorization: Bearer apograph_…' \
  -H 'Content-Type: application/json' \
  -d '{"values":{"title":"…","coverImage":"<asset id>"}}'

Raster images get their derivatives generated on upload, exactly as an admin upload does.

Requires a full token and media:create.

Pass alt on an unattended import

?alt= exists because nothing else in an unattended import ever will. An importer that skips it produces a library of images that nothing describes, and a later pass to fix that is far more work than the query parameter. The copilot’s write path prompts for alt text for the same reason.

Attribution

uploaded_by is not null, and a token is not a person — so an upload is attributed to whoever minted the token. That keeps the library’s uploader column meaningful and names an accountable human.

It is emphatically not an authorization step: the token’s own scope already decided the call was allowed, and that user’s role grants are never consulted. A token whose creator is no longer on record cannot upload.

Serving bytes

GET /api/v1/media/assets/:id/raw?variant=thumb

This is the URL that public content reads hand out for a media field, and unlike the admin’s equivalent it is fetchable with the same bearer token as the read that produced it.

?variant=thumb or ?variant=preview serves a derivative when one was generated, and the original otherwise.

An asset outside the request’s workspace is the same 404 as one that does not exist.

The response may be a redirect

When the deployment runs MEDIA_DIRECT_SERVE=signed-url, this route answers 302 to a short-lived URL at the storage backend rather than streaming the bytes itself. Authorization is unchanged and runs first — the redirect only decides how already-permitted bytes travel.

Follow redirects in your client. curl needs -L; most HTTP libraries do it by default. See media storage.

These URLs are not public — plan for that

The route requires the bearer token, so you cannot put it in a public <img src>: a browser will not send your token, and you would not want it to, since the token also reads and possibly writes your content.

Two shapes that work:

  • Proxy the bytes through your own server, which holds the token.
  • Copy assets to a CDN or object store at build time, and rewrite the URLs in your rendered output.

For a statically generated site the second is usually right, and it is what the absence of a public asset URL pushes you towards.

What a token cannot do

Renaming, moving, folder management and deletion are session-only. The token scope withholds media:update and media:delete.

The line is that attaching an image to a record is content authoring, while curating the library is administration. A full token does the first and not the second.

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

Upload limits

A single upload is capped by maxUploadBytes, which defaults to 50 MB and is set in the plugin’s configuration — the same cap the admin’s upload route enforces. See media storage.

Exceeding it is a rejected upload, not a truncated one.