Media
Endpoints for managing media files attached to pages and site-level media, their .meta.yaml metadata, the manual order of site media folders, and files saved by blueprint file fields. Reading needs api.media.read and changing anything needs api.media.write. Page media also follows the page's o...
Endpoints for managing media files attached to pages and site-level media, their .meta.yaml metadata, the manual order of site media folders, and files saved by blueprint file fields. Reading needs api.media.read and changing anything needs api.media.write. Page media also follows the page's own access rules, so a page that denies you read or update access returns 403. Thumbnails are public.
List Page Media
/pages/{route}/media
Parameters
| Name | Type | Description |
|---|---|---|
| route required | string | The page route |
| filter optional | string | Metadata filter as `field:operator:value` (or `field:value`). Repeatable via `filter[]=`. Operators: == != > >= < <= in contains. |
| sort optional | string | Metadata (or intrinsic filename/size/modified) field to sort by |
| order optional | string | Sort direction: asc (default) or desc |
{"data": [{"filename": "photo.jpg", "url": "/user/pages/01.blog/photo.jpg", "type": "image/jpeg", "size": 245000, "alt": "Sunset over the bay", "dimensions": {"width": 1920, "height": 1080}, "thumbnail_url": "/api/v1/thumbnails/3f2a9c1e4b5d6a7f8e9d0c1b2a394857.jpg", "modified": "2026-09-01T10:15:00+00:00"}]}
Response Codes
Filtering and sorting by metadata
When your media carry metadata via .meta.yaml sidecars, you can filter and sort the listing by those values instead of pulling the whole set. This mirrors the Twig collection query methods but is bound to the metadata schema configured under Plugins → API → Media Metadata (plugins.api.media_metadata.fields).
Filter
Each filter clause is field:operator:value. Repeat it with filter[]= to apply several (they are ANDed):
GET /pages/blog/media?filter=rating:>=:3&filter[]=tags:contains:sunset
Available operators are ==, !=, >, >=, <, <=, in, and contains. You can also omit the operator (filter=copyright:Jane Doe), in which case it is inferred from the field type — contains for a tags field, == otherwise. For the in operator, give a comma-separated set: filter=rating:in:4,5.
Sort
GET /pages/blog/media?sort=rating&order=desc
sort accepts any configured metadata field plus the intrinsic filename, size, and modified keys. order is asc (default) or desc; media missing the sorted field always sort last. (dir is accepted as an alias for order.)
Rules and limits
- Schema-bound. Only fields defined in
media_metadata.fieldsare filterable and sortable; unknown fields are ignored. Atagsfield accepts onlyin/contains. - Validation. An unknown operator, a malformed clause, an invalid
sortfield, or more than 10filterclauses returns422. Filtering rides the existingapi.media.readpermission and exposes no metadata values the caller could not already read. - Cap. At most 10
filterclauses per request.
Upload Page Media
/pages/{route}/media
Parameters
| Name | Type | Description |
|---|---|---|
| route required | string | The page route |
| file required | file | File to upload (multipart/form-data). Any field name works, including nested array names such as `files[]`, and several files may be sent in one request. |
| random_name optional | boolean | Replace the file name with a random one, keeping the extension (default: false) |
| avoid_overwriting optional | boolean | Add a timestamp prefix instead of replacing an existing file with the same name (default: false) |
| accept optional | string | Comma-separated allowlist of extensions (`.pdf`, `*.jpg`) or MIME patterns (`image/*`) |
| filesize optional | number | Per-field maximum size in MB. Can only tighten the 64 MB cap. |
{"data": [{"filename": "photo.jpg", "url": "/user/pages/01.blog/photo.jpg", "type": "image/jpeg", "size": 245000, "dimensions": {"width": 1920, "height": 1080}, "thumbnail_url": "/api/v1/thumbnails/3f2a9c1e4b5d6a7f8e9d0c1b2a394857.jpg", "modified": "2026-09-01T10:15:00+00:00"}]}
Response Codes
Send the file as a multipart/form-data request rather than JSON. The Content-Type header should be set to multipart/form-data.
The response lists all of the page's media after the upload, not only the new files, and the Location header points at the page's media collection. Each file is capped at 64 MB. A file must have an extension, and no dot-separated part of its name may be on security.uploads_dangerous_extensions (so shell.php.jpg is refused). SVG content is sanitized when it arrives.
The optional random_name, avoid_overwriting, accept and filesize form fields apply a blueprint file field's upload settings on top of those checks.
Delete Page Media
/pages/{route}/media/{filename}
Parameters
| Name | Type | Description |
|---|---|---|
| route required | string | The page route |
| filename required | string | The media filename to delete |
Response Codes
Removes the file and its .meta.yaml sidecar, if present. Retina variants of the file ([email protected], [email protected], and so on) and their sidecars are removed too, so an image stored only as a retina variant can still be deleted by its base name.
List Site Media
/media
Parameters
| Name | Type | Description |
|---|---|---|
| page optional | integer | Page number for pagination (default: 1) |
| per_page optional | integer | Number of results per page (default: 20, max: 1000, both configurable) |
| path optional | string | Subfolder path relative to the media root directory |
| search optional | string | Case-insensitive recursive filename search across all subfolders. Cannot be combined with `filter` or `sort`. |
| type optional | string | Filter by media type: image, video, audio, or document (anything that is not image, video or audio) |
| filter optional | string | Metadata filter as `field:operator:value` (or `field:value`). Repeatable via `filter[]=`, up to 10 clauses. Same rules as page media. |
| sort optional | string | Metadata (or intrinsic filename/size/modified) field to sort by. Overrides the folder's manual order. |
| order optional | string | Sort direction: asc (default) or desc |
{"data": [{"filename": "hero.jpg", "path": "blog", "url": "/user/media/blog/hero.jpg", "type": "image/jpeg", "size": 184320, "dimensions": {"width": 1600, "height": 900}, "thumbnail_url": "/api/v1/thumbnails/3f2a9c1e4b5d6a7f8e9d0c1b2a394857.jpg", "modified": "2026-09-01T10:15:00+00:00"}], "meta": {"pagination": {"page": 1, "per_page": 20, "total": 1, "total_pages": 1}, "path": "blog", "folders": [{"name": "2026", "path": "blog/2026", "children_count": 0, "file_count": 4}], "ordered": false}, "links": {"self": "/api/v1/media?page=1&per_page=20"}}
Response Codes
Returns media files and folders from the user/media directory. Supports browsing subfolders via the path parameter, recursive search via search, filtering by file type via type, and filtering and sorting by .meta.yaml metadata via filter, sort and order (see List Page Media for the filter syntax). The response includes a folders array in the meta object listing immediate subdirectories at the current path, and meta.ordered says whether the folder has a saved manual order.
Files follow the folder's saved manual order (see Set Media Order) when one exists, otherwise natural filename order. .meta.yaml sidecars and the media_order.yaml order file are never listed, in a folder listing or in search results.
A folder that does not exist returns an empty list, with meta.pagination echoing the requested page and per_page and no meta.ordered. In search mode, meta.path is empty, meta.folders is empty, meta.search holds the search term, and meta.ordered is absent.
Upload Site Media
/media
Parameters
| Name | Type | Description |
|---|---|---|
| path optional | string | Query parameter: subfolder path to upload into (created automatically if it does not exist) |
| file required | file | File to upload (multipart/form-data). Any field name works, including nested array names such as `files[]`, and several files may be sent in one request. |
| random_name optional | boolean | Replace the file name with a random one, keeping the extension (default: false) |
| avoid_overwriting optional | boolean | Add a timestamp prefix instead of replacing an existing file with the same name (default: false) |
| accept optional | string | Comma-separated allowlist of extensions (`.pdf`, `*.jpg`) or MIME patterns (`image/*`) |
| filesize optional | number | Per-field maximum size in MB. Can only tighten the 64 MB cap. |
{"data": [{"filename": "hero.jpg", "path": "blog", "url": "/user/media/blog/hero.jpg", "type": "image/jpeg", "size": 184320, "dimensions": {"width": 1600, "height": 900}, "thumbnail_url": "/api/v1/thumbnails/3f2a9c1e4b5d6a7f8e9d0c1b2a394857.jpg", "modified": "2026-09-01T10:15:00+00:00"}]}
Response Codes
Upload one or more files to the user/media directory. Use the path query parameter to upload into a subfolder. The request body should use multipart/form-data with a file field. Maximum upload size per file is 64 MB.
The response lists only the files saved by this request. A file must have an extension, and no dot-separated part of its name may be on security.uploads_dangerous_extensions. SVG content is sanitized when it arrives. Without avoid_overwriting, a file with the same name is replaced. A 422 stops the request at that file, and files saved before it are kept.
Delete Site Media
/media/{filename}
Parameters
| Name | Type | Description |
|---|---|---|
| filename required | string | The media filename to delete. May include a subfolder path (e.g. blog/hero.jpg). |
Response Codes
Remove a file and its .meta.yaml sidecar (if present) from the user/media directory, and drop it from the folder's manual order. The filename parameter supports subfolder paths.
Create Media Folder
/media/folders
{
"path": "blog/2026"
}
{"data": {"name": "2026", "path": "blog/2026", "children_count": 0, "file_count": 0}}
Response Codes
Create a new folder in the user/media directory. Parent directories are created automatically. The request body must include a path property with the relative folder path to create. No path segment may start with a period or be ... The Location header points at the new folder's listing (/media?path=...).
Delete Media Folder
/media/folders/{path}
Parameters
| Name | Type | Description |
|---|---|---|
| path required | string | Folder path relative to the media root |
Response Codes
Remove an empty folder from the user/media directory. The folder must contain no files or subdirectories, and that includes hidden files, .meta.yaml sidecars and a media_order.yaml order file.
Rename Site Media
/media/rename
{
"from": "blog/hero.jpg",
"to": "blog/banner.jpg"
}
{"data": {"filename": "banner.jpg", "path": "blog", "url": "/user/media/blog/banner.jpg", "type": "image/jpeg", "size": 184320, "dimensions": {"width": 1600, "height": 900}, "thumbnail_url": "/api/v1/thumbnails/3f2a9c1e4b5d6a7f8e9d0c1b2a394857.jpg", "modified": "2026-09-01T10:15:00+00:00"}}
Response Codes
Rename a file or move it to a different folder within user/media. Both from and to are relative paths. The destination directory is created automatically if needed. Any .meta.yaml sidecar file is also moved, and the folders' manual orders are kept in step. The response is the file at its new location.
The new name is cleaned before it is used: whitespace becomes a dash, characters other than letters, digits, ., _ and - are dropped, and the source file's extension is always kept (any extension given in to is replaced). So {"from": "hero.jpg", "to": "My Banner.png"} produces My-Banner.jpg. If the cleaned name is the same as the source, nothing changes and the file is returned as it is. A cleaned name with no letters or digits left returns 422.
Rename Media Folder
/media/folders/rename
{
"from": "blog/old-name",
"to": "blog/new-name"
}
{"data": {"name": "new-name", "path": "blog/new-name", "children_count": 2, "file_count": 14}}
Response Codes
Rename a folder within the user/media directory. All contents of the folder are preserved. Both from and to are relative paths from the media root.
The response carries the renamed folder's real children_count (subfolders) and file_count (media files, not counting sidecars or the order file), the same counts the folder listing reports, so a client can update the entry without fetching the listing again.
Serve Thumbnail
/thumbnails/{file}
Parameters
| Name | Type | Description |
|---|---|---|
| file required | string | Thumbnail filename (basename only). |
Response Codes
Thumbnails are stored in cache://api/thumbnails/. They are generated when an image is serialized in a media response, never by this endpoint, so a thumbnail that is not cached yet (for example after a cache clear) returns 404 until the media listing that contains it is fetched again.
Get Page Media Metadata
/pages/{route}/media/{filename}/meta
Parameters
| Name | Type | Description |
|---|---|---|
| route required | string | The page route |
| filename required | string | The media filename |
{"data": {"filename": "photo.jpg", "has_meta": true, "fields": [{"key": "alt", "label": "Alt Text", "type": "text", "value": "Sunset over the bay"}, {"key": "title", "label": "Title", "type": "text", "value": ""}, {"key": "caption", "label": "Caption", "type": "textarea", "value": ""}, {"key": "description", "label": "Description", "type": "textarea", "value": ""}, {"key": "tags", "label": "Tags", "type": "tags", "value": ["beach", "summer"]}], "extra": {"width": 1920, "height": 1080}}}
Response Codes
Returns one entry in fields for each editable field configured under Plugins → API → Media Metadata (plugins.api.media_metadata.fields), with its current value. When nothing is configured, the defaults are alt, title, caption, description and tags. A text or textarea field's value is a string (empty when unset), and a tags field's value is a list of strings.
Any other keys stored in the sidecar (EXIF data, dimensions, upload info) are returned as read-only data in extra. has_meta says whether a sidecar file exists at all.
Update Page Media Metadata
/pages/{route}/media/{filename}/meta
Parameters
| Name | Type | Description |
|---|---|---|
| route required | string | The page route |
| filename required | string | The media filename |
{
"fields": {
"alt": "Sunset over the bay",
"tags": ["beach", "summer"]
}
}
{"data": {"filename": "photo.jpg", "has_meta": true, "fields": [{"key": "alt", "label": "Alt Text", "type": "text", "value": "Sunset over the bay"}, {"key": "title", "label": "Title", "type": "text", "value": ""}, {"key": "caption", "label": "Caption", "type": "textarea", "value": ""}, {"key": "description", "label": "Description", "type": "textarea", "value": ""}, {"key": "tags", "label": "Tags", "type": "tags", "value": ["beach", "summer"]}], "extra": {"width": 1920, "height": 1080}}}
Response Codes
Send the values in a fields object, or as a bare map of field keys to values. Only configured fields present in the request are changed, and an empty value (an empty string, or an empty list for a tags field) removes that field. Keys that aren't configured are ignored, and every other key already in the sidecar is kept. The response is the updated metadata, in the same format as Get Page Media Metadata.
Values render into <img> attributes on the site, so they are cleaned on save: HTML tags and control characters are stripped, single-line fields have their whitespace collapsed, and each value is cut to plugins.api.media_metadata.max_length characters (2000 by default). A tags value may be a list of strings or one comma-separated string; duplicates and blanks are dropped and at most 50 tags are kept. Reserved keys that Grav manages itself (width, height, mime, size, filesize, modified, upload, type) can never be written here.
If every key is removed, the sidecar file is deleted.
Delete Page Media Metadata
/pages/{route}/media/{filename}/meta
Parameters
| Name | Type | Description |
|---|---|---|
| route required | string | The page route |
| filename required | string | The media filename |
Response Codes
Removes every configured metadata field from the file's .meta.yaml sidecar. Other keys, such as EXIF data or dimensions, are kept, and the sidecar file is deleted only when nothing else is left in it. The media file itself is not touched.
Serve Raw Site Media
/media/raw/{path}
Parameters
| Name | Type | Description |
|---|---|---|
| path required | string | File path relative to the media root. May contain `/` (e.g. photos/beach.jpg). |
Response Codes
Media listings normally give each file a direct static URL. When the site media folder sits somewhere the web server refuses to serve, such as a multi-site user/env/<host>/media folder (Grav's shipped .htaccess and nginx configs deny user/env, user/config and user/accounts), the listing's url points here instead.
Only images, video, audio, fonts and PDFs are served. Any other file type returns 404, so this route can't be used to read source or configuration files. The response is sent inline with Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox and X-Content-Type-Options: nosniff, so an uploaded SVG can't run script when it is opened directly, and with Cache-Control: private, max-age=600.
Because a browser <img> element can't send an auth header, this route accepts the site's own session cookie, and also a JWT passed as a ?token= query parameter.
Get Site Media Metadata
/media/meta
Parameters
| Name | Type | Description |
|---|---|---|
| path required | string | Query parameter: file path relative to the media root (e.g. blog/hero.jpg) |
{"data": {"filename": "hero.jpg", "has_meta": true, "fields": [{"key": "alt", "label": "Alt Text", "type": "text", "value": "Sunset over the bay"}, {"key": "title", "label": "Title", "type": "text", "value": ""}, {"key": "caption", "label": "Caption", "type": "textarea", "value": ""}, {"key": "description", "label": "Description", "type": "textarea", "value": ""}, {"key": "tags", "label": "Tags", "type": "tags", "value": ["beach", "summer"]}], "extra": {"width": 1920, "height": 1080}}}
Response Codes
The file is addressed by the path query parameter rather than in the URL, because site media paths contain slashes. The response is the same as for Get Page Media Metadata: one entry per configured field in fields, other sidecar keys in extra, and has_meta saying whether a sidecar exists. filename is the file's base name.
Update Site Media Metadata
/media/meta
Parameters
| Name | Type | Description |
|---|---|---|
| path required | string | Query parameter: file path relative to the media root (e.g. blog/hero.jpg) |
{
"fields": {
"alt": "Sunset over the bay",
"tags": ["beach", "summer"]
}
}
{"data": {"filename": "hero.jpg", "has_meta": true, "fields": [{"key": "alt", "label": "Alt Text", "type": "text", "value": "Sunset over the bay"}, {"key": "title", "label": "Title", "type": "text", "value": ""}, {"key": "caption", "label": "Caption", "type": "textarea", "value": ""}, {"key": "description", "label": "Description", "type": "textarea", "value": ""}, {"key": "tags", "label": "Tags", "type": "tags", "value": ["beach", "summer"]}], "extra": {"width": 1920, "height": 1080}}}
Response Codes
Works the same way as Update Page Media Metadata: only configured fields present in the request change, an empty value removes a field, other sidecar keys are kept, and values are cleaned on save. The response is the updated metadata. To apply the same values to many files in one request, use Batch Update Site Media Metadata.
Delete Site Media Metadata
/media/meta
Parameters
| Name | Type | Description |
|---|---|---|
| path required | string | Query parameter: file path relative to the media root (e.g. blog/hero.jpg) |
Response Codes
Removes every configured metadata field from the file's .meta.yaml sidecar. Other keys are kept, and the sidecar file is deleted only when nothing else is left in it. The media file itself is not touched.
Batch Update Site Media Metadata
/media/batch/meta
{
"files": ["photos/beach.jpg", "photos/dunes.jpg"],
"fields": {
"tags": ["beach", "summer"]
}
}
{"data": {"results": [{"path": "photos/beach.jpg", "status": "success"}, {"path": "photos/missing.jpg", "status": "error", "message": "Media file not found."}], "total": 2, "successful": 1, "failed": 1}}
Response Codes
files lists paths relative to the media root, and fields maps metadata field keys to values. Only the configured fields present in fields are written to each file, so a batch that sets just tags leaves each file's own alt and title alone. An empty value (an empty string, or an empty list for a tags field) removes that field. Values are cleaned the same way as in Update Site Media Metadata.
Each file is handled on its own: a bad path or a missing file is reported in results with status: error and a message, and the rest of the batch still runs. The number of files is capped by plugins.api.batch.max_items (50 by default).
Set Media Order
/media/order
{
"path": "photos",
"order": ["dunes.jpg", "beach.jpg", "sunset.jpg"]
}
Response Codes
path is the folder relative to the media root; leave it out for the root folder. order lists filenames in the order you want. Entries are reduced to base filenames, and blanks are dropped. An empty order removes the saved order.
The order is stored in a media_order.yaml file inside the folder, and List Site Media applies it when listing that folder. Files that aren't in the saved order (new uploads, for example) follow the ordered ones. The order file itself never appears in listings or search results, and it is kept up to date when a file is deleted, renamed or moved.
Blueprint File Upload
/blueprint-upload
Parameters
| Name | Type | Description |
|---|---|---|
| file required | file | File to upload (multipart/form-data). Any field name works, and several files may be sent in one request. |
| destination required | string | The blueprint field's `destination`: a Grav stream (`theme://images/logo`, `user://media`), a `self@:subpath` token resolved against `scope`, or a plain path relative to `user/` |
| scope optional | string | Owner of the blueprint, used to resolve `self@:` destinations: `plugins/<slug>`, `themes/<slug>`, `pages/<route>` or `users/<username>` |
| field optional | string | Full name of the blueprint field the upload belongs to (`custom_css`, `header.stylesheet`). With `scope`, the server looks up the field's `allow_extensions` in the blueprint itself. |
| random_name optional | boolean | Replace the file name with a random one, keeping the extension (default: false) |
| avoid_overwriting optional | boolean | Add a timestamp prefix instead of replacing an existing file with the same name (default: false) |
| accept optional | string | Comma-separated allowlist of extensions (`.pdf`, `*.jpg`) or MIME patterns (`image/*`) |
| filesize optional | number | Per-field maximum size in MB. Can only tighten the 64 MB cap. |
{"data": [{"name": "logo.png", "path": "user/themes/quark/images/logo/logo.png", "size": 20480, "type": "image/png", "url": "https://example.com/user/themes/quark/images/logo/logo.png"}]}
Response Codes
This mirrors classic admin's taskFilesUpload and is what Admin2 uses for blueprint file fields that set a destination. Send it as multipart/form-data. Absolute paths and .. segments in destination are rejected.
The response lists each saved file. path is the logical path under user/, built from destination and scope rather than the resolved disk path, so it still works through symlinked theme or plugin folders. Pass it to Delete Blueprint File to remove the file.
File rules:
- Each file is capped at 64 MB, and the field's own
filesizecan only lower that. - No dot-separated part of the name may be on
security.uploads_dangerous_extensions, soshell.php.jpgis refused. - Config and data files (
yaml,yml,json,twig,env,neon,lock) are refused wherever they would land. Page content (md,markdown) and stylesheets (css,scss,sass,less) are refused too, unless the field named infieldallows them withallow_extensionsin the blueprint that ownsscopeand the upload goes to that field's owndestination. - Uploads into
user/accounts/may only be images (jpg,jpeg,png,gif,webp,svg,avif,bmp,ico), since Grav reads that folder as its account store. - Uploads that resolve into
user/config/oruser/env/*/config/are refused with 403. - SVG files are sanitized after saving.
A self@: destination with a users/<username> scope for your own account needs only api.media.write. For another account it also needs api.users.write (or super user).
Delete Blueprint File
/blueprint-upload
Parameters
| Name | Type | Description |
|---|---|---|
| path required | string | Logical path of the file, relative to the user folder (`user/` prefix optional) |
| scope optional | string | Owner of the field's blueprint: `plugins/<slug>`, `themes/<slug>`, `pages/<route>` or `users/<username>`. Only needed with `field`. |
| field optional | string | Full name of the blueprint field the file belongs to. With `scope`, lets a field's `allow_extensions` cover deleting its own files. |
{
"path": "user/themes/quark/images/logo/logo.png"
}
Response Codes
path is the logical path the upload returned, relative to the user folder; the user/ prefix is optional. .. and null bytes are rejected.
The same extension rules as Blueprint File Upload apply, so config files can't be deleted through this endpoint, and neither can page content (.md, .markdown) or stylesheets (.css, .scss, .sass, .less) unless field and scope name a field whose blueprint allows them with allow_extensions and the file sits in that field's own folder. Under user/accounts/ only image files can be deleted, and without api.users.write (or super user) only your own avatar, meaning a file your account's avatar field points at.
The call is idempotent: a file that no longer exists returns 204.