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/{id}/rename | media:write |
POST | /v1/media/{id}/delete | media:write |
GET | /v1/media/{id}/uses | media:read |
GET | /v1/media/renders | media:read |
POST | /v1/media/renders/{id}/delete | media:write |
GET | /v1/assets/{id} | media:read |
GET | /v1/assets/{id}/download | media:read |
POST | /v1/storage/split | media:write |
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 }
}
}
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.
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.
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
scheduled posts holding it.
curl http://localhost:4000/v1/media/01M2SQSRPWVG87Y0TD6C77XE61/uses \
-H "Authorization: Bearer rw_your_key_here"
{ "uses": [] }
Ask before you delete. An empty uses is the only safe answer.
POST /v1/media/{id}/rename takes { "name": "..." }, 1 to 200 characters. POST /v1/media/{id}/delete removes the file and its bytes.
Rendered clips
The clips a render produced are held separately from uploads, and counted separately against storage.
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"
}
]
}
needed: true means a post still to publish is relying on this clip, and POST /v1/media/renders/{id}/delete will refuse it: the post would reach its turn with nothing to
publish. Once a post has published, been rejected, recalled or replaced, its clip is only kept, and
deleting it frees storage.
POST /v1/storage/split moves the boundary between the two, with
{ "mediaSharePercent": 30 }. 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 or a
media library file. 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.
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 does not fail politely here, it fails with a 500, so there is no error message to read your way out of. Send the file as a form part.
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}. A signed URL in a template
input is a URL that expires.
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.