Media
List the files your organization has uploaded — images, video, documents — with their size, dimensions, folder and URLs, and upload new ones.
Uploading is a create only: there is no API call that replaces, renames or deletes an existing file. Those stay console actions.
Two libraries, one resource
Aglyn stores media in two places, and the API serves both at the same object shape:
| Path | Which library |
|---|---|
GET /v1/media | The organization library — files shared across every site. |
GET /v1/sites/{siteId}/media | One site's own files. |
They are separate stores, not a filter over one store: a file in the organization library does not appear in a site's listing, and vice versa. If you're building an inventory of everything you have, read the organization library once and then each site.
The media object
{
"id": "m_9fK2xQ",
"object": "media",
"fileName": "hero-bakery.jpg",
"contentType": "image/jpeg",
"sizeBytes": 184320,
"width": 2400,
"height": 1260,
"alt": "Loaves cooling on a rack",
"description": null,
"tags": ["hero", "bakery"],
"folderId": "fold_marketing",
"url": "https://firebasestorage.googleapis.com/…?alt=media&token=…",
"cdnUrl": "https://app.aglyn.com/api/media/cdn/org:org_abc123/m_9fK2xQ",
"private": false,
"customMetadata": { "campaign": "spring-launch" },
"embeddedMetadata": {
"format": "jpeg",
"truncated": false,
"fields": [
{ "key": "title", "label": "Title", "group": "description", "value": "Morning bake", "values": null },
{ "key": "keywords", "label": "Keywords", "group": "description", "value": null, "values": ["bread", "bakery"] },
{ "key": "creator", "label": "Creator", "group": "rights", "value": null, "values": ["Dana Ruiz"] },
{ "key": "make", "label": "Camera make", "group": "capture", "value": "FUJIFILM", "values": null }
]
},
"created": "2026-06-11T14:20:03.881Z"
}
| Field | Type | Notes |
|---|---|---|
id | string | Media id. Stable across replacing the file and moving it between folders. |
object | string | Always "media". |
fileName | string | null | Original filename. Not unique — two folders can hold logo.png. |
contentType | string | null | MIME type as stored. |
sizeBytes | integer | Size of the original. This is what counts toward your storage: generated image variants and delivery copies are rebuilt from the original whenever needed, and are not counted. |
width, height | integer | null | Pixel dimensions. null for non-images and for images we couldn't read — never assume an image has them. |
alt | string | null | Alt text. Worth syncing if you're auditing accessibility. |
description | string | null | Free text set in the library. |
tags | array | Library tags. Stored lower-cased and de-duplicated, so match them in lower case — there is no Hero to find. |
folderId | string | null | The folder it's in; null at the library root. |
url | string | null | The durable download URL. Always present. See below. |
cdnUrl | string | null | The CDN URL, or null. See below. |
private | boolean | true for restricted files. |
customMetadata | object | The custom fields set in the library's Details drawer, name → value, all strings. {} when there are none. |
embeddedMetadata | object | null | The details the file carries inside itself. See below. |
created | string | null | ISO 8601. |
url versus cdnUrl — pick deliberately
These are not two spellings of one thing.
urlis the storage download URL. It always exists and always works, and it carries an access token in the query string. Treat it as a secret: it grants whoever holds it the ability to fetch the file. It is the right choice for a server-side pipeline; it is the wrong thing to paste into a public page.cdnUrlis the cached public URL, served from your Aglyn origin. It is what belongs in an<img src>, and every file has one except aprivatefile, where it isnull.
So cdnUrl === null is information, not an omission — it tells you the file has no
publicly cacheable address. Never fall back from cdnUrl to url to fill an src:
that publishes a tokenised link to a file that is null precisely because it wasn't
meant to be public.
// Right: absence is a decision, not a gap.
const src = file.cdnUrl
if (!src) {
// Private. Link through your own authenticated handler, or skip it — don't
// reach for `file.url`.
}
Private files are reachable only through a short-lived signed link the console mints; there is no API endpoint that signs one.
Details inside the file
embeddedMetadata is what the file itself says about itself: a photo's EXIF, IPTC and
XMP (title, caption, keywords, creator, copyright, location, camera), a PDF's document
information and custom properties, an Office document's properties, a video's tags.
It is the same list the console shows under File info.
fieldsis a list, not a map, because a file can carry details Aglyn has no fixed name for. The common ones have stable keys —title,description,keywords,creator,copyright,city,gps,createdAt,make,model— and anything else gets a namespaced key such aspdf|Clientorxmp|http://ns.example.com/|Client. Match onkeyand showlabel.- Exactly one of
valueandvaluesis set. List-valued details (keywords, creators) come invalues; everything else is a string invalue. Dates are ISO 8601, andgpsis"latitude,longitude"in decimal degrees. groupis the section the console files it under:description,rights,location,capture,document,technicalorother.truncatedistruewhen a file carried more than Aglyn stores (150 fields, and 2,000 characters a value), so some were left out.nullmeans the file has not been read yet — it was uploaded before this existed and nobody has opened it since — or it is a type with nothing to read (CSV, ZIP, plain text). It is never the previous file's details: replacing a file reads the new one.
Read-only here: details are edited in the console, where the change is written into the file itself.
The response does not tell you which variants exist
Aglyn generates WebP variants at 160, 320, 480, 640, 768, 960,
1280, 1600, 1920 and 2560 pixels wide when an image is uploaded, up to the
image's own width, and cdnUrl accepts a ?w= parameter to select one:
https://app.aglyn.com/api/media/cdn/org:org_abc123/m_9fK2xQ?w=640
The media object carries no variants field, so the API cannot tell you which of
those widths a particular file actually has. That is a known limitation of this
resource, not something to derive from another field: contentType and width say
what the original is, not what was generated from it. Only the console's media library
— the delivery line in a file's Details drawer — reports the per-asset truth.
Two consequences worth designing around:
- A
?w=width the file doesn't have is not an error. The CDN serves what the plaincdnUrlserves instead of resizing or 404ing. So asrcsetbuilt from every width always renders; the cost of guessing wrong is full-size bytes over a mobile connection, not a broken image. Asrcsetthat stops at the file's ownwidthnever guesses wrong. - The plain
cdnUrlis not always the original. A JPEG, PNG or WebP larger than 2560 pixels on its long edge, carrying details such as a GPS position, or relying on a camera's rotation flag is served as an upright copy in the same format, at most 2560 pixels on the long edge, with the details removed. Add?download=1for the original exactly as uploaded. - Non-images never have variants. An SVG, a PDF, a video or a document is served as
uploaded whatever
?w=says.
Nothing about ?w= applies when cdnUrl is null — there is no CDN address to add a
parameter to.
Endpoints
List organization library files
GET /v1/media — scope media:read.
Paginated, ordered by media id.
| Param | Notes |
|---|---|
folder | Filter to one folder by its id (folderId), exact match. Files at the root have folderId: null and can't be selected with this param. |
limit, cursor | Standard pagination. |
curl "https://app.aglyn.com/api/v1/media?limit=100" \
-H "Authorization: Bearer aglyn_sk_…"
The filter is on the folder id, not its name. There is no endpoint that lists
folders yet, so the practical route is to page all files once and group them by
folderId yourself — which you'd want anyway, since folders nest.
Deleted files are filtered out after the page is read, so a page can be shorter than
limit while has_more is true. Trust has_more.
List a site's files
GET /v1/sites/{siteId}/media — scope media:read. Same params, same shape.
curl "https://app.aglyn.com/api/v1/sites/host_demo/media" \
-H "Authorization: Bearer aglyn_sk_…"
Upload a file
POST /v1/media — scope media:write, uploads to the organization library.
POST /v1/sites/{siteId}/media — same call, uploads to one site's library.
Accepts an Idempotency-Key.
Body — JSON, with the file's bytes base64-encoded. There is no multipart form.
| Field | Type | Required | Notes |
|---|---|---|---|
data | string | yes | The file's bytes, base64. Anything that isn't valid base64 is a 400 — we never store a partially-decoded file. |
contentType | string | yes | MIME type. Must be on the allowed list. |
fileName | string | no | Defaults to upload. Truncated at 200 characters. |
folderId | string | no | Put the file in a folder, by its id. |
alt | string | no | Alt text. Worth setting — it's the field an accessibility audit reads. |
private | boolean | no | true stores it restricted: no cdnUrl, no public link. |
curl -X POST "https://app.aglyn.com/api/v1/media" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2b9f1c4e-…" \
-d "{\"fileName\":\"hero.jpg\",\"contentType\":\"image/jpeg\",\"data\":\"$(base64 < hero.jpg)\"}"
Returns 201 with the media object — or 200 with the
original object when an Idempotency-Key replays. Image variants are generated exactly
as they are for a console upload, so an uploaded image gets its cdnUrl and its ?w=
widths without a second call.
Size and type limits
Because the bytes travel inside a JSON body, base64 inflates them by about a third — budget for that when you size a batch.
| Family | Limit |
|---|---|
Images (image/*, including SVG) | 15 MB |
| PDF, Word, Excel, CSV, text, Markdown, JSON, ZIP | 10 MB |
Video (mp4, webm, quicktime) | 25 MB, while video uploads are paused: see below |
| PowerPoint | 10 MB |
Anything outside the allowed types returns 415 unsupported_media_type; anything past
its ceiling returns 413 payload_too_large. The size is measured on the decoded
bytes, not on anything you declare.
Document uploads need a plan that includes them; images do not.
Video uploads are paused. A video returns 403 forbidden with
code: "video_uploads_paused" on every plan, and nothing is stored. The refusal comes
before your Idempotency-Key is claimed, so the same key still works once video
uploads resume.
What we check, and what we don't
Worth stating exactly, because "the platform accepted it" is not the same as "the platform vetted it":
- We check the declared content type against an allowlist and refuse the rest.
- We measure the real decoded size against the per-type ceiling.
- We check that the bytes match the type you declared. A file labeled
application/pdfhas to start with a PDF header, a.docxhas to be a ZIP, aimage/pnghas to carry a PNG signature. A mismatch is refused with415(type_mismatch). Text types —text/plain,text/csv,text/markdown,application/json,image/svg+xml— have no header to check and are exempt. - We refuse executables outright, whatever they claim to be: Windows
.exe, Linux ELF, macOS Mach-O, installer packages and Windows shortcuts are rejected under every content type (415,executable_bytes). - We refuse Office documents that carry macros. A
.docx,.xlsxor.pptxcontaining avbaProject.binentry is rejected (415,macro_payload) — including a macro-enabled file simply renamed to a non-macro extension. - We sanitize SVGs, stripping script and other active content before storing.
- We hash the file (SHA-256) and refuse anything matching a taken-down asset.
- We do not scan for malware. No upload path on the platform does. The checks above are structural — they establish that a file is the kind of thing it says it is, not that its contents are safe. A malicious PDF that is a genuine PDF passes all of them, and an accepted file has not been examined for anything harmful inside it.
Treat files uploaded through your own integration as you would any other content you are responsible for.
Quota
Uploads count against your storage allowance, the same one console uploads count
against — there is no separate API allowance and no per-upload charge. An upload that
would cross the band returns 403 plan_required with code: "storage_quota", and
nothing is stored: no file, no metering.
A quota refusal releases the Idempotency-Key, so if you free up space or upgrade,
retrying with the same key genuinely re-runs rather than replaying the refusal.
Check GET /v1/usage before a large batch — dataStorageMb tells you
what is left.
Retrieve a file
GET /v1/media/{mediaId} — scope media:read.
curl "https://app.aglyn.com/api/v1/media/m_9fK2xQ" \
-H "Authorization: Bearer aglyn_sk_…"
Returns 404 not_found ("No such file") for an unknown id and for one that has
been deleted.
Recipes
Audit images missing alt text
const missing = files.filter(
(f) => f.contentType?.startsWith('image/') && !f.alt?.trim(),
)
console.log(`${missing.length} images have no alt text`)
for (const f of missing) console.log(` ${f.fileName} (${f.id})`)
Find what's eating your storage quota
const byType = {}
for (const f of files) {
const group = (f.contentType ?? 'unknown').split('/')[0]
byType[group] = (byType[group] ?? 0) + f.sizeBytes
}
const mb = (b) => (b / 1024 / 1024).toFixed(1)
for (const [group, bytes] of Object.entries(byType).sort((a, b) => b[1] - a[1])) {
console.log(`${group.padEnd(12)} ${mb(bytes)} MB`)
}
This measures originals, which is also what your storage is billed on: generated image variants and delivery copies are not counted. Treat it as "which files should I clean up" rather than as a reconciliation of your invoice, which totals every library in the workspace. The billed figure is on the billing page.
Mirror the library to disk
for (const f of files) {
if (f.private) continue // no fetchable link
const res = await fetch(f.url) // `url`, not `cdnUrl` — server side
await writeFile(`./backup/${f.id}-${f.fileName}`, Buffer.from(await res.arrayBuffer()))
}
Prefix the filename with the id: fileName is not unique across folders, and a plain
fileName mirror silently overwrites.
Errors
| Status | type | When |
|---|---|---|
400 | bad_request | data is not valid base64. |
403 | insufficient_scope | Key lacks media:read, or media:write to upload. |
403 | plan_required | code: "storage_quota" — the upload would cross your storage band. Or the file type needs a higher plan. |
403 | forbidden | code: "video_uploads_paused" — video uploads are paused on every plan. Nothing is stored. |
404 | not_found | Unknown or unowned site; unknown or deleted file. |
405 | method_not_allowed | A method the path doesn't take — POST is accepted on the collection, never on /media/{id}. |
413 | payload_too_large | Past the ceiling for that type. |
415 | unsupported_media_type | Content type not on the allowed list. |
451 | unavailable_for_legal_reasons | The file matches a taken-down asset. |
Media needs no commerce entitlement — an organization with no store still reads its own files.
Related
- Products —
mediaUrlspoint at these files. - Media library — organizing what you upload.
- Variant widths — what
?w=can ask for, and what a file's own delivery line says it has. - Conventions — pagination, ordering, errors.