Media API

/api/v1/media is a thin REST surface over the media library. What makes it worth using is what happens during the upload: an image is resized, converted to AVIF and WebP, cut into thumbnails and indexed before the response comes back. There is no queue to poll and no worker to babysit.

The media library
An upload through the API appears in this library with the same variants a manual upload gets.

Every call needs an API key, and every write needs the upload_files capability - admin, editor and author roles all have it.

Listing files

GET /api/v1/media takes three parameters and only three: page (1-indexed, default 1), per_page (default 20) and type.

type filters on the stored MIME type and accepts image, video, audio, or file - where file means everything that is not image, video or audio, so PDFs, spreadsheets and archives land there. Leave type out and you get the whole library. There is no keyword search, no uploader filter and no sort parameter on this endpoint; results always come back newest first.

curl -H "X-API-Key: YOUR_TOKEN" \
  "https://yoursite.com/api/v1/media?type=image&per_page=20"
{
    "success": true,
    "data": {
        "items": [
            {
                "id": 45,
                "filename": "cover-8f3a12c4.avif",
                "original_filename": "cover.jpg",
                "path": "images/2026/04/cover-8f3a12c4.avif",
                "url": "https://yoursite.com/uploads/images/2026/04/cover-8f3a12c4.avif",
                "type": "image",
                "mime_type": "image/avif",
                "size": 61204,
                "width": 1600,
                "height": 900,
                "alt_text": "Plate of scrambled eggs",
                "title": "cover",
                "caption": "",
                "user_id": 1,
                "created_at": "2026-04-10 08:55:00",
                "formats": {
                    "avif": { "path": "images/2026/04/cover-8f3a12c4.avif", "size": 61204 },
                    "webp": { "path": "images/2026/04/cover-8f3a12c4.webp", "size": 88431 }
                },
                "thumbnails": { "thumbnail": { "…": "…" }, "medium": { "…": "…" }, "large": { "…": "…" } }
            }
        ],
        "total": 412,
        "pages": 21,
        "current_page": 1
    }
}

formats, thumbnails and srcset live in the row's JSON metadata column and are merged into the object as it is returned, so they appear as ordinary top-level keys. GET /api/v1/media/{id} returns one item in the same shape, or 404 if the id does not exist.

Uploading a file

Send a multipart form with the binary in a field named file. Two optional text fields ride along: alt becomes the alt text and title becomes the library title. Leave alt empty and jekcms derives a readable default from the filename rather than shipping an empty alt="" into your pages.

curl -X POST https://yoursite.com/api/v1/media \
  -H "X-API-Key: YOUR_TOKEN" \
  -F "file=@/path/to/cover.jpg" \
  -F "alt=Plate of scrambled eggs"

The response is the upload result - success, the stored id, the final path and url, size, mime_type, the generated formats and thumbnails. For images, the path already points at the converted file, not at what you sent.

Uploading from a URL

The same endpoint accepts a JSON body with a url instead of a multipart file, which saves an automation from downloading the image locally first:

curl -X POST https://yoursite.com/api/v1/media \
  -H "X-API-Key: YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://images.example.com/food/eggs.jpg", "alt": "Plate of scrambled eggs"}'

The fetch is deliberately narrow. Only http and https are followed, redirects are not followed at all, and the hostname is resolved before the request is made: anything pointing at a private, loopback, link-local or cloud-metadata address is refused. The download stops at 25 MB and times out at 60 seconds. A refusal comes back as HTTP 200 with success: false and a reason code in the error text - private-or-reserved-ip, scheme-not-allowed, internal-host, metadata-or-linklocal, dns-failed or invalid-url - so check the flag, not just the status code.

Base64 uploads are not part of this endpoint. They live on the automation surface as POST /api/v1/webhook/media-base64, which takes data and filename and accepts images only.

What conversion actually does

Conversion runs inline, during the request. An image is read, resized if either side exceeds the configured maximum (2560 pixels by default), then written as AVIF and as WebP. AVIF becomes the primary file and the one the database row points at; WebP is the fallback. Once both exist, the original JPEG, PNG or GIF is deleted - the modern formats are the library, not an addition to it. A file uploaded as WebP or AVIF is kept as-is.

Four thumbnail sizes are produced alongside: a cropped 400×400 square, 800×800 and 1600×1600 fitted inside those bounds, and a cropped 1000×1500 for Pinterest.

Quality and size settings live in Settings → Media: maximum dimension (800–4096), JPEG quality, WebP quality (55–95, default 82) and AVIF quality (40–80, default 60). The defaults are chosen to be visually lossless at a fraction of the original weight.

If the server has no GD extension, conversion is skipped rather than fatal: the file is stored in its original format and the response says so. That is the one case where you get back a JPEG where you expected an AVIF.

Updating metadata

PUT or PATCH on /api/v1/media/{id} changes alt_text, title, caption and description. Nothing else on the row is writable through the API, and the response is just {"success": true}.

Deleting

DELETE /api/v1/media/{id} removes the database row, the primary file, the WebP and AVIF siblings and every generated thumbnail.

It does not check whether anything still points at the file. A deleted image that is still set as a post's cover, or still referenced inside a post body, leaves a broken reference behind - so check usage before you delete, especially from a script.

Limits

MAX_UPLOAD_SIZE caps uploads at 50 MB, and PHP's own upload_max_filesize and post_max_size cap them again - on shared hosting the effective ceiling is usually the PHP one, so check there first when a large file is refused. Remote URL fetches have their own, lower cap of 25 MB.

Extensions are allow-listed, not MIME-sniffed from the client. Images accept jpg, jpeg, png, gif, webp and avif. Documents and archives accept pdf, doc, docx, xls, xlsx, zip and txt. Anything else is refused with an explanatory error in the response body.

SVG is deliberately excluded. An SVG is served inline as image/svg+xml and can carry scripts, which would make an upload form a stored-XSS surface on your own origin. Site logos and interface icons are shipped as theme assets instead.

Status codes

| Code | When | |---|---| | 200 | Listing, read, update, delete or upload handled | | 400 | Neither a file field nor a url was sent, or the media id is missing | | 401 | Missing, unknown, inactive or expired API key | | 403 | The key's role lacks upload_files | | 404 | No media with that id | | 405 | Method not supported on that path | | 429 | Over the per-IP hourly rate limit |

Validation failures inside an upload - wrong extension, file too large, corrupt image, refused remote URL - return HTTP 200 with success: false and a message, because they are results of the upload rather than faults in the request. Read the flag.

Be the first to know

New features, release notes and CMS guides. We send a couple of emails a month.