Media library

How files get into a brand, what is checked on the way, and where they are stored.

6 min read

How media works

Every file belongs to one brand. You upload it once, and pages, menus, settings and theme options refer to it by its id. The API resolves that id to URLs, size and alt text when it builds an answer, so a block never stores a link to a file.

All media calls need a signed in session and the X-Brand-Id header. Roles:

ActionRole
List, upload, edit alt text, move files, create foldersAny member (editor and up)
Delete a file, replace a file, delete a folderPublisher or admin
Storage provider and assets base URLAdmin

The upload flow

Files do not pass through the API. The browser sends them straight to storage with a signed address, then tells the API it is done. The API then verifies what arrived.

Step 1: start the upload

POST/v1/manage/media/uploads
requestjson
{
  "filename": "hero.png",
  "mime": "image/png",
  "size": 482113,
  "hash": "3f5a...sha256 hex of the file...",
  "folder_id": null
}

hash is the SHA-256 of the file in hex (shasum -a 256 hero.png). size is capped per type, and a bigger file is a 422:

TypeLargest file
image/jpeg, image/png, image/webp, image/avif, image/gif25 MB
image/svg+xml2 MB
application/pdf50 MB

Any other mime is refused. If the brand already has a ready file with the same hash, the answer returns that asset with duplicate: true and upload: null, and you skip the next steps. Otherwise the answer holds the pending asset and an upload object: url, method (PUT), headers and expires_at.

Step 2: send the bytes

PUT the file to upload.url and send exactly upload.headers. The declared length and checksum are signed into the address, so storage refuses any other bytes. The address points at a staging key that works once. With the local storage provider the address is a signed route on the API instead.

terminalbash
curl -X PUT "$UPLOAD_URL" -H "Content-Length: 482113" \
  -H "x-amz-checksum-sha256: $CHECKSUM" --data-binary @hero.png

Use the headers the API returned, not the ones in this example. Your bucket needs CORS for the dashboard origin when the browser does the upload.

Step 3: complete

POST/v1/manage/media/{asset}/complete

Send { "hash": "..." }, the same hash as in step 1. The API checks the size and checksum of the stored object, reads the real width and height, writes a 480 px thumbnail and marks the asset ready. A wrong size or checksum is a 422 and the file is dropped. A second call is a 422 too, and the old upload address can no longer change the stored file. An identical file already in the brand returns the existing asset with deduplicated: true.

SVG files are cleaned

An SVG can carry script, so it is never served as uploaded. At step 3 the API reads the staged bytes, removes scripts, foreignObject, animation elements, event handler attributes and any reference that is not a local #fragment, and stores only the cleaned file. A file that is not valid XML, has a doctype or entity, or is not rooted at <svg> is a 422 and nothing is stored.

Folders and the list

GET /v1/manage/media takes folder_id (or none for files without a folder), q (file name), ids (up to 40 comma separated ids), sort (created_at, filename, size), direction and per_page (1 to 100, default 40). Each row carries usage_count.

Folders are a resource at /v1/manage/media-folders. A folder has a name (up to 120 characters) and an optional parent_id. Deleting a folder that still holds files or folders returns 409 until you pass move_contents=1, which moves everything inside up one level.

Move files with PATCH /v1/manage/media/{asset} and a folder_id, or in bulk:

POST/v1/manage/media/bulk
requestjson
{ "action": "move_to_folder", "folder_id": "01m...", "ids": ["01m...", "01n..."] }

action is move_to_folder (any member) or delete (publisher or admin), with 1 to 100 ids. The answer is always 200 with { action, done, failed, results[] }. Each result is moved, deleted, not_found or in_use. A file that is in use is never deleted.

Alt text and focal point

PATCH/v1/manage/media/{asset}
requestjson
{
  "alt": { "en": "A technician replacing a phone screen", "id": "Teknisi mengganti layar ponsel" },
  "focal_x": 0.4,
  "focal_y": 0.3
}

Alt text is stored per language, up to 500 characters each, and every key must be a locale of the brand. focal_x and focal_y are between 0 and 1. You can also rename the file with filename.

A media prop in a block needs alt text, unless the prop sets decorative: true. The publish warnings and the dashboard health card flag images without alt text. When an edit changes alt or the focal point of a file that is in use, the site is purged.

Replace a file

POST/v1/manage/media/{asset}/replace

A publisher sends { "mime", "size", "hash" } and gets a new upload object, then repeats steps 2 and 3. The file keeps its id, alt text and every reference. The kind must match: an image can only replace an image, a PDF only a PDF. The API deletes the old object and thumbnail and queues a media.replaced purge webhook, so caches refresh.

A file in use cannot be deleted

GET/v1/manage/media/{asset}/usage

The answer lists where the file is used: entries, fragments, menus, settings, templates and theme, with a total. DELETE /v1/manage/media/{asset} (publisher) answers 422 while the file is in the published or latest revision of an entry, a part, a menu, a setting such as the logo or the default share image, or the theme. The answer names the entries and other_usages. Remove the references first, then delete.

Publishing or rolling back a revision that points at a deleted file is refused too.

Where files are stored

By default files go to the shared AtlasCMS storage. An admin can give a brand its own bucket.

GET/v1/manage/media/storage
PUT/v1/manage/media/storage
requestjson
{
  "provider": "r2",
  "endpoint": "https://<account>.r2.cloudflarestorage.com",
  "region": "auto",
  "bucket": "acme-media",
  "public_url": "https://media.acme.example",
  "access_key_id": "...",
  "secret_access_key": "..."
}

provider is shared, s3, r2, b2, custom or local. A remote provider needs bucket and region, and r2, b2 and custom also need endpoint. Endpoints and public_url must be HTTPS on port 443 and resolve to a public address. Credentials are write only: the API stores them encrypted and GET only reports credentials_set. Changing the provider, endpoint or bucket needs fresh credentials. POST /v1/manage/media/storage/test checks the connection without saving. Files already uploaded stay reachable after you switch.

Switching does not move files

New uploads go to the new storage. Existing files keep their keys and stay where they are.

Assets base URL

The media setting decides which address public files are served from and how new uploads are named. An admin writes it with PUT /v1/manage/settings:

requestjson
{ "settings": { "media": { "assets_base_url": "https://cdn.acme.example/atlas", "upload_structure": "year_month", "keep_filename": false } } }

assets_base_url is null (use the disk address) or an HTTPS address on port 443 with a normal host name. upload_structure is year_month, year, flat or a custom folder pattern. keep_filename: true keeps the cleaned original name instead of a random id. The database stores keys only, so changing the base URL moves every public link at once. Links pasted into rich text are not rewritten.

POST /v1/manage/media/assets-base-url/test (admin) writes a small test file and checks that the address serves it.

Was this page helpful?

Suggest a change