Two routes, deliberately:
POST /api/v1/media/assets upload
GET /api/v1/media/assets/:id/raw stream the bytesUpload 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.
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 | Parameter | Effect |
|---|---|
file | The multipart file part. Required. |
folderId | Which library folder to upload into. Omit for the workspace root. |
alt | A 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=thumbThis 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.
| Operation | Public API | Admin API |
|---|---|---|
| Upload an asset | Yes | Yes |
| Read an asset’s bytes | Yes | Yes |
| Rename, set alt, move | No | Yes |
| Create or delete folders | No | Yes |
| Delete an asset | No | Yes |
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.
What to read next
- Expanding relations and media — how a content read hands out these URLs in the first place.
- Media storage — the five backends, direct serve, and the upload cap.