Media library
The media library holds files a workspace uploads once and uses in any clip: images, MP4 video,
sound and fonts. Content refers to a file as media://{id}, never by URL, and Reelwire hands the
renderer a private download of it for each job. There is no lasting public address for a library
file.
Nothing is resized, re-encoded or thumbnailed on the way in. A file is stored byte for byte and counted at the size it arrived.
| Method | Path | Permission |
|---|---|---|
GET | /v1/media | media:read |
POST | /v1/media | media:write |
POST | /v1/media/import | media:write |
POST | /v1/media/upload-link | media:write |
POST | /v1/media/{id}/rename | media:write |
POST | /v1/media/{id}/delete | media:write |
GET | /v1/media/{id}/uses | media:read |
GET | /v1/media/renders | media:read |
GET | /v1/media/renders/{id}/reuse | media:read |
POST | /v1/media/renders/{id}/delete | media:write |
GET | /v1/assets/{id} | media:read |
GET | /v1/assets/{id}/download | none: the signature in the URL |
POST | /v1/storage/split | signed-in owner |
The AI assistant makes pictures, videos and fonts, and keeping one of them puts it in this library by the same rules as an upload.
Uploading is multipart, and only multipart
POST /v1/media takes exactly one file as multipart/form-data. Not base64 in a JSON body, not a
URL to fetch. The field name does not matter: the route takes the first file part it finds, and
sends only one.
With curl, that is -F and an @path. Give the type explicitly, because curl guesses from the
extension and guesses fonts wrong:
curl http://localhost:4000/v1/media \
-H "Authorization: Bearer rw_your_key_here" \
-F "file=@docs-example-logo.png;type=image/png"
Do not also set Content-Type: multipart/form-data by hand. The boundary is part of that
header, and a hand-written one without it makes the body unparseable.
{
"file": {
"id": "01M2SQSRPWVG87Y0TD6C77XE61",
"name": "docs-example-logo.png",
"mimeType": "image/png",
"kind": "Image",
"byteSize": 74,
"createdAt": "2026-09-18T07:48:21.347Z",
"uploadedBy": "apikey:01M2SQKKZ3ZPXK8RB7BK5QJ3GH",
"ref": "media://01M2SQSRPWVG87Y0TD6C77XE61",
"url": "http://localhost:4000/v1/assets/01M2SQSRPWVG87Y0TD6C77XE61/download?exp=1789721301&sig=8eeefc2bed6398b1bfcadc69286fce5436c6a28f4c29f5321091351e160c527f"
},
"storage": {
"maxMb": 500,
"maxBytes": 524288000,
"usedBytes": 28182659,
"usedBy": { "media": 10821961, "renders": 17360698 },
"remainingBytes": 496105341,
"includedMb": 500,
"mediaPercent": 30,
"media": { "maxBytes": 157286400, "usedBytes": 10821961, "maxMb": 150, "usedMb": 10.3, "remainingBytes": 146464439 },
"clips": { "maxBytes": 367001600, "usedBytes": 17360698, "maxMb": 350, "usedMb": 16.6, "remainingBytes": 349640902 },
"split": { "minPercent": 10, "maxPercent": 90 }
}
}
201. ref is what you put in content. url is a signed preview, good for an hour, for showing
the file to a person. Store the id, never the URL. Content may also name a file by its name,
media://docs-example-logo.png, which is what the templates' own examples do.
uploadedBy records which credential did it, apikey:{keyId} or user:{email}.
Extra form fields
Two optional text parts change what an upload means:
| Field | What it does |
|---|---|
replace | "true" allows the upload to take over a name already in the library. |
replaces | A media file id. Replaces that one file, keeps its name with the new file's extension, and points every brand slot and every post that referenced the old file at the new one. |
A replaces upload must be the same kind of file: a logo does not become a video. The new file gets
a new id rather than taking over the old one's, so nothing that cached the old bytes by id can
draw the old picture under the new name.
Without either, a name already in the library is a 409:
{
"error": {
"type": "https://reelwire.io/errors/conflict",
"code": "conflict",
"message": "docs-example-logo.png is already in the media library. Upload it again and choose to replace it.",
"requestId": "01M2SREXKAJRENJMBM4S1CXZ74",
"details": []
}
}
What the library takes
| Kind | Types | Limit |
|---|---|---|
| Image | image/png, image/jpeg, image/webp, image/gif, image/svg+xml | 2048 by 2048 pixels |
| Video | video/mp4 | 5 MB |
| Audio | audio/mpeg, audio/wav | 20 MB |
| Font | font/woff2, font/woff, font/ttf, font/otf | 10 MB |
50 MB is the ceiling no single upload may exceed, whatever its kind, and the workspace's storage allowance still bounds the total.
The pixel limit is read from the file's own header rather than from what the client claims, and an image whose size cannot be read is refused, because it cannot be shown to be within the limit. SVG is exempt: it is a drawing, drawn sharp at whatever size a clip needs.
Fonts are recognised by their file extension rather than their declared type. Browsers disagree
about what to call an .otf, so the ending decides.
Two other ways in
From a public address. POST /v1/media/import fetches a file from the web into the library,
which is how an assistant brings in a picture it was pointed at. It takes url, and optionally
name and replace: true, and answers exactly as an upload does. The address has to be public
HTTPS with no user name or password in it; every address it resolves to or redirects to, at most
three redirects, is checked, and an internal one is refused.
curl http://localhost:4000/v1/media/import \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{"url":"https://www.example.com/press/logo.png","name":"press-logo.png"}'
Through a one-time upload link. POST /v1/media/upload-link hands out a link that takes files
into this library with no credential, for something that holds a file but no key, such as an
assistant's code sandbox. Send { "replace": true } if the files may take over names already in
the library.
{
"url": "http://localhost:4000/v1/upload-files?token=...",
"expiresAt": "2026-09-18T08:03:21.000Z",
"maxFiles": 10,
"method": "POST",
"field": "file",
"example": "curl -F \"file=@picture.png\" \"http://localhost:4000/v1/upload-files?token=...\""
}
The link lasts fifteen minutes and takes up to ten files, one per request, each held to the rules
above. A name form field alongside the file renames it on the way in. It stops working at once if
the key that asked for it is revoked.
The rest of the library
GET /v1/media lists every file, newest first, with the storage it takes. It is not paged: the
whole library comes back in one response, because a library is hundreds of files and the screens
search and sort it themselves.
{
"files": [
{
"id": "01M2S2CPZVEWKJ6TW9EATDYMJB",
"name": "test-font-roboto-mono.ttf",
"mimeType": "font/ttf",
"kind": "Font",
"byteSize": 183700,
"createdAt": "2026-09-18T01:34:13.501Z",
"uploadedBy": "user:enterprise2-1@example.com",
"ref": "media://01M2S2CPZVEWKJ6TW9EATDYMJB",
"url": "http://localhost:4000/v1/assets/01M2S2CPZVEWKJ6TW9EATDYMJB/download?exp=1789721209&sig=a7f7bb9d4bc230b28b59b634056a10071941c066b25f7ed16ebbefd0762594d2"
}
],
"storage": { "maxMb": 500, "usedBytes": 28182585 }
}
GET /v1/media/{id}/uses says what would break if you deleted a file: the brand slots and the
posts on post lists, still to go out, holding it.
curl http://localhost:4000/v1/media/01M2SQSRPWVG87Y0TD6C77XE61/uses \
-H "Authorization: Bearer rw_your_key_here"
{
"uses": [
{ "kind": "brand", "id": "01M1VFRWE7VHP3H54ESRJQ0SAJ", "label": "Brand Brand 1: Logo" }
]
}
Ask before you delete. An empty uses is the only safe answer.
POST /v1/media/{id}/rename takes { "name": "..." }, 1 to 200 characters, and keeps the file's
ending whatever you type: a .png stays a .png. References are by id, so nothing that uses the
file notices. POST /v1/media/{id}/delete removes the file and its bytes, and answers with the
storage as it now stands. Every brand slot holding it is emptied in the same step; a post still
waiting keeps its content and is drawn without the picture.
Rendered clips
The clips a render produced are held separately from uploads, and counted separately against
storage. Kept direct exports are listed here too, with source: "export"
where a post's clip says source: "post". When the clips' share is full, the oldest clip of either
kind is removed to make room for a new one.
curl http://localhost:4000/v1/media/renders \
-H "Authorization: Bearer rw_your_key_here"
{
"clips": [
{
"id": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"kind": "video",
"mimeType": "video/mp4",
"byteSize": 740641,
"ratio": "9x16",
"locale": "en",
"width": 1080,
"height": 1920,
"durationMs": 10000,
"createdAt": "2026-09-18T07:46:28.697Z",
"templateId": "tpl_a9c3fdb1b5c9",
"templateName": "Template 1",
"channelName": "Channel 10 en",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=1789721392&sig=ae02f120693a5043646e04d5bd65a899deea94add53450aea4e9e510053bf1fb",
"needed": false,
"neededBecause": "Awaiting approval",
"source": "post"
}
]
}
GET /v1/media/renders/{id}/reuse answers with what a clip was made from: its template, variant,
version, brandId, ratio, locale and data, the content in the template's own shape with
locales for further languages. It is the same answer as a post's
reuse, for a post's clip or a kept direct export, ready to send to
POST /v1/direct-export/video after a change. It reads and changes nothing.
neededBecause says what a post still using the clip is waiting for. POST /v1/media/renders/{id}/delete deletes the clip anyway, and every post of its job still waiting on it
is cancelled with it, which is what cancelled in the answer counts:
{ "deleted": true, "cancelled": 1, "storage": { "maxMb": 500, "usedBytes": 27442018 } }
The one refusal is needed: true: a post is publishing this clip at this moment, and deleting it is
a 409 until that has finished. A clip whose posts have published, been rejected or deleted is only
kept, and deleting it frees storage.
POST /v1/storage/split moves the boundary between the two, with { "mediaSharePercent": 30 }.
It changes what the workspace's paid storage is spent on, so it is the owner's, signed in: a key is
refused whatever it holds. It must be an integer, and the split block on any storage response says
the range it may take.
Signed downloads
GET /v1/assets/{id}/download serves the bytes for any asset: a rendered clip, a brand file, a
media library file, a kept direct export or something the AI assistant made. It is authorised by the
exp and sig in the URL rather than by a bearer token, because these URLs are handed to a browser
and pasted into a <video src>, and a video element cannot attach an Authorization header. The
signature names exactly one asset and expires, which is what makes that safe. It supports Range, so
a browser can seek. Add &save=1, and &name= for the file name, to have it saved rather than
opened.
GET /v1/assets/{id} returns metadata without the bytes, and takes a bearer token. It answers for
rendered clips only: a media library id here is a 404, because a library file's metadata is on
/v1/media.
curl http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7 \
-H "Authorization: Bearer rw_your_key_here"
{
"assetId": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"kind": "video",
"ratio": "9x16",
"locale": "en",
"mimeType": "video/mp4",
"width": 1080,
"height": 1920,
"durationMs": 10000,
"byteSize": 740641,
"sha256": "19c629bfd5a1ec22a1fb9baed2c043ce23d523d01c5886ffd0d4278be444e5c8",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=1789722005&sig=48542502fdc9cc4b9d0368cefd53b04b7f71f696a3926e0f7e4aadfb9ee5fa23",
"expiresAt": "2026-09-18T09:00:05.000Z"
}
Verify sha256 after a download. A signed URL is not a credential to store: keep the id and ask for
a fresh URL.
Mistakes people make
Sending JSON. Upload is multipart. A JSON body is a 415 that says so. Send the file as a form
part, or use /v1/media/import when what you hold is an address.
Setting Content-Type: multipart/form-data yourself. It has to carry the boundary. Let your
HTTP client write it.
Sending more than one file. One per request. The limit is enforced, not advisory.
Referring to a file by its signed URL. Content takes media://{id}, and a web address in a
picture field is refused. A signed URL would be a URL that expires, and any other address is one the
renderer will not fetch.
Deleting a file without checking /uses. A brand loses its logo, and the next clip is drawn
without one.
Trusting curl's guess at a font's type. Pass ;type=font/otf explicitly, or rely on the
extension, which is what the server does.