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.
In the dashboard this is the Media screen, with three cards: Media (the library), Rendered clips and Storage split.
| 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 |
POST | /v1/media/renders/{id}/copy | 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 |
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, so send
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,
"included": false,
"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}, and is null for
the starter files a workspace came with. included is true for exactly those files while they are
still as they came: they count against no storage (see What a new workspace starts with),
and byteSize still says how big they are.
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 at the new one every brand in which /uses (below) finds the old file, and every post on a post list that named it. |
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 | 50 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.
Straight off a phone
What a phone makes fits none of these, so the library makes it fit as it arrives, by every way in (upload, upload link, import):
- A PNG, JPEG or WebP larger than 2048 pixels a side is scaled down to fit, in its own format, turned the way its camera said, its EXIF (the place it was taken included) dropped. A GIF is not, and has to fit.
- An iPhone's HEIC or HEIF photo becomes a JPEG, turned the way its camera held it, scaled down where it is
larger than 2048 pixels a side, its metadata dropped, its name ending in
.jpg. Send it asimage/heicorimage/heif, or with a.heicor.heifname. - A video that is not an H.264 MP4 the library could keep: a MOV, HEVC, more than 1920 pixels a side, or
over 50 MB. It becomes an H.264 MP4 at most 1920 pixels a side, under 45 MB, its metadata dropped, its name
ending in
.mp4. An upload may be up to 300 MB on its way in (an import, 50 MB), and a long video takes a minute or two to convert.
A file that already fits is kept byte for byte, as always.
Fonts and sound files are recognised by their file extension rather than their declared type.
Browsers disagree about what to call an .otf or a .wav, so the ending decides.
A file that does not fit in what is left of the media share of the storage is refused with a 429 that says how much room there is. The library never deletes anything to make room: that is for you to do, or to give media a larger share (below).
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, replace: true and replaces (the id of one file this takes the place of, as Replace on its
row does), 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, or { "replaces": "<file id>" } for one file that takes the place of that file, as
Replace on its row does: the link then takes that one file (maxFiles is 1).
{
"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 and at most 30 requests a
minute, 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.
What a new workspace starts with
Every new workspace finds these files in its library, so the first clip it renders shows what a template and a brand can do rather than grey boxes:
| File | What it is for |
|---|---|
example-image.jpg, example-image.png, example-image.svg | The pictures the reference templates' own examples ask for, by name. |
example-image-with-animation.svg | The same landscape with its weather changing, an animated SVG. Workspaces made before it was added were given it too. |
example-logo.png, example-badge.png | The Default brand's logo and badge. |
example-background-image-universal.jpg | The Default brand's background: one picture, a 1440 square made to be cropped from the centre, which fills every shape. |
example-stinger-video-universal.mp4, example-intro-video-universal.mp4, example-outro-video-universal.mp4 | The Default brand's stinger, intro and outro: one clip each, a 1440 square made to be cropped from the centre, which plays in every shape. They are switched off until the brand turns them on. |
An earlier version of this set gave a background, a stinger, an intro and an outro for each of the five shapes as well, twenty files more. They are gone: they were taken out of every library that still held them as they were given, and out of every brand that wore them, which plays the universal file instead.
| example-font.ttf | Bebas Neue, the Default brand's headline typeface. |
| example-sound.mp3 | Thirty seconds of music, the Default brand's soundtrack. |
Workspaces made before the square background and the stinger, intro and outro clips were added were given them too. They are the workspace's own copies, free to rename, replace or delete, and they are included: they count against no storage, and the library says Included under Size. The mark holds only while a file is exactly as it came. A file that replaces one counts in full, and so does any upload, even of the very same bytes, so the free set can neither grow nor be swapped for something larger. A template's example finds its pictures by name, so it draws them only while the library still has a file of that name. See the Default brand.
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,
"included": false,
"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 brands that wear it,
wherever the file sits in the brand (its logo, badge, a background or a clip for one shape such as
intro-9x16, a font or the soundtrack, a language's own files included), and the posts on post lists,
still to go out, whose content names it. The Default brand takes its typeface and soundtrack from the
library in exactly that way.
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 one of those brand slots that held 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, in the clips' share of the storage,
and are the Rendered clips card on the Media screen. Kept
direct exports, the videos made with Create clip in Clip
studio or through the API, 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, with the same effect on its posts as deleting it (below); a clip being handed
over at that moment is passed by.
A post's clip has a small preview still beside it. The still is not listed here, takes its room in the same share, and goes when its clip goes.
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_dd7e6b264fb8",
"templateName": "Big number",
"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, under reuse, 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.
POST /v1/media/renders/{id}/copy, Copy to library on the Rendered clips card, puts a copy of a
clip, a post's or a kept direct export, into the media library as a file of its own, to use again
like an upload: as a brand's intro or outro, or a video in a template's content. The clip stays where it is and rotates as before; the copy stays until
it is deleted. It is stored by the library's own rules, so the size of one file and the storage
allowance apply, and a refusal is the one an upload of the same file would get. The body may name the
file, { "name": "Summer teaser" }, whose ending is always the clip's own, and a name of your own
that the library already holds is a 409. Left out, the name is the template, shape, language and the
day it was rendered, made free with -2, -3 where taken, so copying twice makes two files. A clip
id that is gone, removed to make room or deleted, is a 404. The answer is 201 with the library
file, as an upload's:
{
"file": {
"id": "01M2SR3V8N0Q4K7XG5D2W9PJ6T",
"name": "Big number 9x16 en 2026-10-03.mp4",
"mimeType": "video/mp4",
"kind": "Video",
"byteSize": 740641,
"createdAt": "2026-10-03T09:12:44.120Z",
"uploadedBy": "user:enterprise2-1@example.com",
"ref": "media://01M2SR3V8N0Q4K7XG5D2W9PJ6T",
"url": "http://localhost:4000/v1/assets/01M2SR3V8N0Q4K7XG5D2W9PJ6T/download?exp=..."
},
"storage": { "maxMb": 500, "usedBytes": 28182659 }
}
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 being handed over with this clip at this moment (state
PUBLISHING), and deleting it is a 409 until that has finished. A clip whose posts have been handed
over, or have ended in any other way, is only kept, and deleting it frees storage; a link to it that
was handed over then answers 404.
POST /v1/storage/split moves the boundary between the two, with { "mediaSharePercent": 30 },
which is the Storage split card on the Media screen. 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; neither side may
go below a tenth or below what it already holds, and a value outside the range is a 403.
Signed downloads
GET /v1/assets/{id}/download serves the bytes for any asset: a rendered clip, a brand file, a
media library file or a kept direct export. 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. A wrong or expired
signature is a 403: ask for a fresh address. It supports Range, so a browser can seek. Add
&save=1, and &name= for the file name, to have it saved rather than opened. A font is served to
any origin (Access-Control-Allow-Origin: *), so a web page or a mail app elsewhere can load it by
its signed address; nothing else is.
GET /v1/assets/{id} returns metadata without the bytes, and takes a bearer token. It answers for
the rendered clips of posts only: a media library id here is a 404, because a library file's
metadata is on /v1/media, and so is a kept direct export's, which /v1/media/renders lists.
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.