Skip to main content

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.

MethodPathPermission
GET/v1/mediamedia:read
POST/v1/mediamedia:write
POST/v1/media/{id}/renamemedia:write
POST/v1/media/{id}/deletemedia:write
GET/v1/media/{id}/usesmedia:read
GET/v1/media/rendersmedia:read
POST/v1/media/renders/{id}/deletemedia:write
GET/v1/assets/{id}media:read
GET/v1/assets/{id}/downloadmedia:read
POST/v1/storage/splitmedia: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:

FieldWhat it does
replace"true" allows the upload to take over a name already in the library.
replacesA 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

KindTypesLimit
Imageimage/png, image/jpeg, image/webp, image/gif, image/svg+xml2048 by 2048 pixels
Videovideo/mp45 MB
Audioaudio/mpeg, audio/wav20 MB
Fontfont/woff2, font/woff, font/ttf, font/otf10 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.