API reference
Base URL: https://<your-domain>/api/v1. All responses are JSON:
{ "success": true, "data": { } }{ "success": false, "error": { "code": "MEDIA_NOT_FOUND", "message": "Media file not found." } }API responses are never cached (Cache-Control: no-store). Every response carries an X-Request-Id header; quote it when reporting a problem.
Authentication
Two ways to authenticate:
- API key (scripts, servers, other sites): send
Authorization: Bearer mm_live_…. Create keys in the dashboard under API keys. A key is shown once at creation; only its hash is stored. Keys can expire and can be revoked at any time. - Browser session (the dashboard): an HTTP-only cookie set by
POST /auth/login. Cookie-authenticated requests that change data must come from the app's own origin (CSRF protection).
API keys can use the media, folder and usage endpoints. Key management, password changes and admin endpoints require a browser session, so a leaked key cannot create more keys or escalate.
Rate limits
When a limit is hit the API answers 429 RATE_LIMITED with a Retry-After header (seconds).
- API calls: 600 per minute per key or user
- Uploads: 120 per minute per user
- Login: 5 attempts per 15 minutes per email, 30 per 15 minutes per IP
- Registration: 5 per hour per IP; password changes: 5 per 15 minutes
Media
Upload a file
POST /media?filename=<name>&folderId=<optional folder id>
The request body is the raw file (not multipart). Allowed types are checked from the file content: JPEG, PNG, WebP, GIF, AVIF, MP4, M4V, WebM, MOV, MP3, OGG, WAV, M4A and PDF. The extension in filename must match the content. The size limit is set by the server (MAX_UPLOAD_SIZE, 100 MB by default).
curl -X POST "https://media.example.com/api/v1/media?filename=photo.jpg" \
-H "Authorization: Bearer $MEDIA_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpgResponse 201 with { media, duplicate: false }. If the same file (same SHA-256) is already in your library and deduplication is on (default), the response is 200 with the existing item and duplicate: true; no copy is stored and the existing item keeps its folder.
Images and videos are processed in the background: status is PROCESSING first and becomes READY (or FAILED with processingError). Poll GET /media/:id if you need the thumbnails right away. The original url works immediately.
A media object:
{
"id": "cmh1x2y3z0000abcd1234efgh",
"publicId": "a1B2c3D4e5F6g7H8",
"filename": "photo.jpg",
"originalFilename": "photo.jpg",
"mimeType": "image/jpeg",
"extension": "jpg",
"kind": "IMAGE",
"size": 482113,
"width": 2400,
"height": 1600,
"duration": null,
"status": "READY",
"folderId": null,
"url": "https://media.example.com/m/a1B2c3D4e5F6g7H8.jpg",
"thumbnailUrl": "https://media.example.com/m/a1B2c3D4e5F6g7H8/thumbnail.webp",
"posterUrl": null,
"variants": [
{ "name": "thumbnail", "format": "webp", "width": 320, "height": 213, "size": 9120, "url": "…/thumbnail.webp" }
],
"createdAt": "2026-10-26T09:30:00.000Z"
}List files
GET /media
Query parameters, all optional:
folderId: a folder id, orrootfor files not in any folder. Omit to list everything.q: search in file name, original name and MIME type.kind:IMAGE,VIDEO,AUDIOorDOCUMENT.sort:createdAt(default),filenameorsize;order:desc(default) orasc.limit: 1 to 100, default 50.cursor: thenextCursorvalue from the previous page.
Response: { items: Media[], nextCursor: string | null }.
Get, rename, move, delete
GET /media/:idreturns{ media }.PATCH /media/:idwith{ "filename": "new-name.jpg" }and/or{ "folderId": "<id>" | null }.DELETE /media/:iddeletes the file, its variants and its metadata. Its public URL stops working (a CDN may keep a cached copy until it expires).
Folders
GET /foldersreturns every folder of the caller (flat, withparentId,mediaCount,childCount).POST /folderswith{ "name": "Products", "parentId": null }. Names are unique within a parent.PATCH /folders/:idwith{ "name": "…" }and/or{ "parentId": "<id>" | null }. Moving a folder into itself or a subfolder is refused.DELETE /folders/:iddeletes an empty folder. A folder with files or subfolders answers409 FOLDER_NOT_EMPTYwithdetails: { mediaCount, subfolderCount }; repeat with?contents=deleteto delete it together with everything inside.
Usage
GET /usage?days=7|30|90 returns { current: { storageBytes, mediaCount }, totals, series }, where series has one entry per UTC day with storageBytes, bandwidthBytes, requests, uploads and downloads.
API keys (browser session only)
GET /api-keyslists keys (never the key itself).POST /api-keyswith{ "name": "Website", "expiresInDays": 90 }(ornullfor no expiry) returns{ apiKey, key }. Thekeyis shown only in this response.DELETE /api-keys/:idrevokes the key immediately.
Account
POST /auth/loginwith{ email, password }sets the session cookie.POST /auth/logoutends the session.GET /auth/mereturns the current user (works with API keys too).PATCH /auth/mewith{ name }.POST /auth/registerwith{ name, email, password }, only whenALLOW_PUBLIC_REGISTRATION=true.POST /auth/passwordwith{ currentPassword, newPassword }signs out all other sessions.
Passwords need at least 10 characters with letters and numbers (max 72 bytes).
Admin (ADMIN role, browser session)
GET /admin/users?page=1&limit=25&q=lists users with file counts and storage.POST /admin/userswith{ name, email, password, role }.PATCH /admin/users/:idwith any of{ name, role, disabled, password }. Disabling or resetting the password signs the user out.DELETE /admin/users/:iddeletes the user, their keys and all their files.
Admins cannot demote, disable or delete themselves, and the last active admin cannot be removed.
Public delivery
Public URLs do not need authentication and are safe to embed anywhere:
- Original:
/m/<publicId>.<ext> - Variant:
/m/<publicId>/<name>.<format>, for examplethumbnail.webp,medium.avif,poster.webp - Add
?download=1to force a download.
Originals are served Cache-Control: public, max-age=31536000, immutable; sizes (variants) public, max-age=86400, stale-while-revalidate=604800, because they are rendered again when watermark settings change. All responses have an ETag and Last-Modified, support Range requests (video seeking) and HEAD, and send Access-Control-Allow-Origin: *.
With WATERMARK=true, an image's original URL (/m/<id>.<ext>) serves its watermarked full size (WebP) instead of the uploaded file. The owner gets the clean original from GET /api/v1/media/<id>/original (session or API key, Content-Disposition: attachment, no-store).
Error codes
400 VALIDATION_ERROR(withdetails.fieldErrors),INVALID_PASSWORD401 UNAUTHORIZED,INVALID_CREDENTIALS,INVALID_API_KEY403 FORBIDDEN,CSRF_FAILED,REGISTRATION_DISABLED404 NOT_FOUND,MEDIA_NOT_FOUND,FOLDER_NOT_FOUND,API_KEY_NOT_FOUND,USER_NOT_FOUND409 FOLDER_EXISTS,FOLDER_NOT_EMPTY,EMAIL_TAKEN,API_KEY_LIMIT,LAST_ADMIN413 FILE_TOO_LARGE415 UNSUPPORTED_FILE_TYPE429 RATE_LIMITED500 INTERNAL_ERROR(details are logged on the server with the request id)
Example: upload from Node.js
import { readFile } from "node:fs/promises";
const res = await fetch("https://media.example.com/api/v1/media?filename=banner.png", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MEDIA_KEY}`, "Content-Type": "image/png" },
body: await readFile("banner.png"),
});
const { success, data, error } = await res.json();
if (!success) throw new Error(`${error.code}: ${error.message}`);
console.log(data.media.url);