Skip to main content

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:

PathWhich library
GET /v1/mediaThe organization library — files shared across every site.
GET /v1/sites/{siteId}/mediaOne 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"
}
FieldTypeNotes
idstringMedia id. Stable across replacing the file and moving it between folders.
objectstringAlways "media".
fileNamestring | nullOriginal filename. Not unique — two folders can hold logo.png.
contentTypestring | nullMIME type as stored.
sizeBytesintegerSize 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, heightinteger | nullPixel dimensions. null for non-images and for images we couldn't read — never assume an image has them.
altstring | nullAlt text. Worth syncing if you're auditing accessibility.
descriptionstring | nullFree text set in the library.
tagsarrayLibrary tags. Stored lower-cased and de-duplicated, so match them in lower case — there is no Hero to find.
folderIdstring | nullThe folder it's in; null at the library root.
urlstring | nullThe durable download URL. Always present. See below.
cdnUrlstring | nullThe CDN URL, or null. See below.
privatebooleantrue for restricted files.
customMetadataobjectThe custom fields set in the library's Details drawer, name → value, all strings. {} when there are none.
embeddedMetadataobject | nullThe details the file carries inside itself. See below.
createdstring | nullISO 8601.

url versus cdnUrl — pick deliberately​

These are not two spellings of one thing.

  • url is 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.
  • cdnUrl is the cached public URL, served from your Aglyn origin. It is what belongs in an <img src>, and every file has one except a private file, where it is null.

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.

  • fields is 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 as pdf|Client or xmp|http://ns.example.com/|Client. Match on key and show label.
  • Exactly one of value and values is set. List-valued details (keywords, creators) come in values; everything else is a string in value. Dates are ISO 8601, and gps is "latitude,longitude" in decimal degrees.
  • group is the section the console files it under: description, rights, location, capture, document, technical or other.
  • truncated is true when a file carried more than Aglyn stores (150 fields, and 2,000 characters a value), so some were left out.
  • null means 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 plain cdnUrl serves instead of resizing or 404ing. So a srcset built from every width always renders; the cost of guessing wrong is full-size bytes over a mobile connection, not a broken image. A srcset that stops at the file's own width never guesses wrong.
  • The plain cdnUrl is 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=1 for 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.

ParamNotes
folderFilter 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, cursorStandard 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.

FieldTypeRequiredNotes
datastringyesThe file's bytes, base64. Anything that isn't valid base64 is a 400 — we never store a partially-decoded file.
contentTypestringyesMIME type. Must be on the allowed list.
fileNamestringnoDefaults to upload. Truncated at 200 characters.
folderIdstringnoPut the file in a folder, by its id.
altstringnoAlt text. Worth setting — it's the field an accessibility audit reads.
privatebooleannotrue 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.

FamilyLimit
Images (image/*, including SVG)15 MB
PDF, Word, Excel, CSV, text, Markdown, JSON, ZIP10 MB
Video (mp4, webm, quicktime)25 MB, while video uploads are paused: see below
PowerPoint10 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/pdf has to start with a PDF header, a .docx has to be a ZIP, a image/png has to carry a PNG signature. A mismatch is refused with 415 (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, .xlsx or .pptx containing a vbaProject.bin entry 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​

StatustypeWhen
400bad_requestdata is not valid base64.
403insufficient_scopeKey lacks media:read, or media:write to upload.
403plan_requiredcode: "storage_quota" — the upload would cross your storage band. Or the file type needs a higher plan.
403forbiddencode: "video_uploads_paused" — video uploads are paused on every plan. Nothing is stored.
404not_foundUnknown or unowned site; unknown or deleted file.
405method_not_allowedA method the path doesn't take — POST is accepted on the collection, never on /media/{id}.
413payload_too_largePast the ceiling for that type.
415unsupported_media_typeContent type not on the allowed list.
451unavailable_for_legal_reasonsThe file matches a taken-down asset.

Media needs no commerce entitlement — an organization with no store still reads its own files.