Renders and direct export
Every clip a post publishes is drawn by a render job. Direct export is the other way to use the same renderer: fill a template in and take the video away. No render job row, no post, no channel. The finished video is kept with the workspace's rendered clips, so it is still there when you come back for it.
| Method | Path | Permission |
|---|---|---|
GET | /v1/render-jobs | renders:read |
GET | /v1/render-jobs/{id}/package | renders:read |
GET | /v1/renders/quota | allowance:read |
POST | /v1/direct-export/timing | renders:write |
POST | /v1/direct-export/video | renders:write |
POST | /v1/direct-export/batch | renders:write |
GET | /v1/direct-export/queue | renders:read |
GET | /v1/direct-export/video/{jobId}/reuse | renders:read |
POST | /v1/direct-export/zip | renders:write |
GET | /v1/direct-export/video/{jobId} | renders:read |
GET | /v1/direct-export/video/{jobId}/download | renders:read |
POST | /v1/direct-export/video/{jobId}/link | renders:write |
POST | /v1/direct-export/video/{jobId}/discard | renders:write |
GET | /v1/direct-export/videos | renders:read |
GET | /v1/direct-export/videos/{jobId} | renders:read |
POST | /v1/direct-export/keyframes | renders:write |
POST | /v1/renders/buy, /v1/renders/keep, /v1/renders/drop | signed-in owner |
What is left this month
curl http://localhost:4000/v1/renders/quota \
-H "Authorization: Bearer rw_your_key_here"
{
"quota": {
"allowance": 1200,
"used": 9,
"extra": 0,
"remaining": 1191,
"periodStart": "2026-09-06T13:50:50.821Z",
"periodEnd": "2026-10-06T13:50:50.821Z",
"overridden": false
},
"seats": { "used": 6, "max": 10 },
"partners": { "used": 0, "max": 100 },
"storage": {
"used": 26.9,
"max": 500,
"usedBytes": 28182585,
"maxBytes": 524288000,
"usedBy": { "media": 10821887, "renders": 17360698 },
"mediaPercent": 30,
"media": { "used": 10.3, "max": 150 },
"clips": { "used": 16.6, "max": 350 },
"split": { "minPercent": 10, "maxPercent": 90 }
}
}
Watch quota.remaining before a batch. periodEnd is when it resets. The answer carries more than
this: where each ceiling comes from, what extras the workspace holds and what more would cost, which
is what the Allowance screen draws.
The allowance is read-only for a key. POST /v1/renders/buy, /keep and /drop change what the
workspace is paying for, so they belong to the owner, signed in: a key is refused whatever its
permissions, and a colleague who is not the owner gets a 403 of their own:
{
"error": {
"type": "https://reelwire.io/errors/forbidden",
"code": "forbidden",
"message": "Only the owner of this workspace can buy more.",
"requestId": "01M2SQWHPGCVYFJR5HM761W5XN",
"details": []
}
}
Render jobs
curl "http://localhost:4000/v1/render-jobs?perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
{
"jobs": [
{
"id": "01M2SQN531MF686B8KWYJA3NV8",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"clip": {
"id": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"mimeType": "video/mp4",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=1789721392&sig=ae02f120693a5043646e04d5bd65a899deea94add53450aea4e9e510053bf1fb"
},
"templateId": "tpl_a9c3fdb1b5c9",
"templateName": "Template 1",
"templateVariant": "Standard",
"templateVersion": "1.0.0",
"state": "COMPLETED",
"outputsRequested": 1,
"outputsSucceeded": 1,
"submittedAt": "2026-09-18T07:45:50.788Z",
"finishedAt": "2026-09-18T07:46:28.715Z",
"error": null,
"priority": 1,
"clipsStored": 1,
"progress": 1,
"outputs": [{ "ratio": "9x16", "locale": "en", "state": "succeeded", "error": null }]
}
],
"total": 7,
"all": 7,
"page": 1,
"perPage": 1,
"facets": { "state": ["COMPLETED"] }
}
A render job runs PENDING, SUBMITTED, RENDERING, DOWNLOADING, STORED and COMPLETED, with
PARTIAL, FAILED, TIMED_OUT and CANCELLED off the side. Filter on it with state.
progress is the mean over the job's outputs, a finished one counting whole, which is why it reads
the same here as on the Posts screen. priority is 1 where the job was queued with priority
rendering. clipsStored fewer than outputsSucceeded means clips were removed to make storage room.
GET /v1/render-jobs/{id}/package returns exactly what the renderer was handed: the template, the
content, the brand, the channel context and the options. It is its own request rather than part of
the list, because a package carries a whole brand document and the list is polled.
Direct export
Ask how long the clip will be, start it, poll, then download it or hand out a link to it.
How long will it be
curl http://localhost:4000/v1/direct-export/timing \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"templateId": "tpl_a9c3fdb1b5c9",
"version": "1.0.0",
"variant": "Standard",
"data": { "clipData": { "headline": "Bund yields close at a three-week high" } }
}'
{ "fps": 30, "frames": 300, "durationMs": 10000 }
Some templates run as long as their content needs, so this is asked while the form is still being
filled in. It costs nothing and stores nothing. It is a POST, so a key needs renders:write for
it.
Start a video
POST /v1/direct-export/video takes the same fields plus the look: brandId, ratio and locale.
curl http://localhost:4000/v1/direct-export/video \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"templateId": "tpl_a9c3fdb1b5c9",
"version": "1.0.0",
"variant": "Standard",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"ratio": "9x16",
"locale": "en",
"data": {
"clipData": {
"headline": "Bund yields close at a three-week high",
"subline": "The ten-year settled at 2.41 per cent",
"source": "Deutsche Finanzagentur"
},
"metaData": { "title": "Bund yields close at a three-week high" }
}
}'
{ "jobId": "01M2SQTF3R3GHBVTR3DQQNH1DK" }
202. version is required here, unlike the format routes: an export is drawn with a build you
named. The content is drawn in the language asked for, with that language's copy from locales
laid over the default, exactly as a channel's clip is.
Everything is checked before anything is submitted, so a mistake is an answer rather than a render that fails a minute later:
| Refusal | Why |
|---|---|
404 unknown_template | The template is not offered to this workspace, or has no such version. |
400 ratio_not_supported | The template does not draw that shape. |
400 schema_validation | The content does not fit the build, exactly as a post list's post is checked. Each detail points at the field. |
429 rate_limited | No videos are left this month, counting exports still rendering. The message names the allowance and the date it resets. |
503 upstream_unavailable | No renderer is free right now. |
Several shapes and languages at once
POST /v1/direct-export/batch takes the same body with ratios and locales in place of ratio and
locale, and starts one video for every shape in every language: at most 24.
curl http://localhost:4000/v1/direct-export/batch \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"templateId": "tpl_a9c3fdb1b5c9",
"version": "1.0.0",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"ratios": ["9x16", "1x1"],
"locales": ["en", "de"],
"data": { "clipData": { "headline": "Bund yields close at a three-week high" }, "locales": { "de": { "clipData": { "headline": "Bundrenditen auf Dreiwochenhoch" } } } }
}'
{
"jobs": [
{ "jobId": "01M2SQTF3R3GHBVTR3DQQNH1DK", "ratio": "9x16", "locale": "en" },
{ "jobId": "01M2SQTF6B1Y8XK2G5E7WQ9D3M", "ratio": "9x16", "locale": "de" },
{ "jobId": "01M2SQTF9C4J7P0R2T6V8N1B5H", "ratio": "1x1", "locale": "en" },
{ "jobId": "01M2SQTFC2D5F8H1K4M7Q0S3V6", "ratio": "1x1", "locale": "de" }
],
"problem": null
}
202. Each job is an ordinary export from then on: polled, downloaded, kept and charged on its own.
The content and every shape are checked, and the allowance is checked for all of them, before the
first is started, so a mistake refuses the whole batch with the same answers as a single video. More
than 24 is a 400 schema_validation. Should the renderers refuse part way through, the jobs that did
start are answered, and problem says how many of how many and why.
Every export, and how far it has got
GET /v1/direct-export/queue lists every export of the workspace, newest first: those still waiting
or rendering, those that failed in the last two hours, and the kept ones (the newest 100). It is one
request for a whole batch, and what the dashboard's Exported videos card shows.
{
"exports": [
{
"jobId": "01M2SQTF3R3GHBVTR3DQQNH1DK",
"templateId": "tpl_a9c3fdb1b5c9",
"templateName": "Template 1",
"variant": "Standard",
"version": "1.0.0",
"ratio": "9x16",
"locale": "en",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"brandName": "Northwind Markets",
"status": "rendering",
"progress": 0.4,
"ahead": 0,
"error": null,
"kept": false,
"keptNote": null,
"byteSize": null,
"durationMs": null,
"createdAt": "2026-09-24T09:57:12.402Z"
}
]
}
status, progress, ahead and keptNote mean what they mean when polling one video, below.
Asking the queue settles every export it lists, so a video that has just finished is kept and
counted, exactly as polling it would. byteSize and durationMs are there once it is kept. Poll it
every few seconds while something renders, not faster.
Make one again
GET /v1/direct-export/video/{jobId}/reuse answers with what an export was made from, in the shape
/v1/media/renders/{id}/reuse gives for a rendered clip: template, variant,
build, brand, shape, language and data, every language included. It works by job id, so for an
export still rendering or one that failed as well as a kept one. Nothing is made; change what needs
changing and start it again with POST /v1/direct-export/video or /batch.
{
"reuse": {
"kind": "export",
"templateId": "tpl_a9c3fdb1b5c9",
"templateName": "Template 1",
"variant": "Standard",
"version": "1.0.0",
"pinnedVersion": "1.0.0",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"ratio": "9x16",
"locale": "en",
"data": { "clipData": { "headline": "Bund yields close at a three-week high" } },
"title": "Copy of Template 1",
"queueId": null
}
}
Several as one zip
POST /v1/direct-export/zip with { "jobIds": [...] }, 1 to 24 of them, answers one zip holding each
.mp4, named by template, variant, version, brand, shape and language:
HTTP/1.1 200 OK
content-type: application/zip
content-disposition: attachment; filename="Reelwire exports 2026-09-24.zip"
Every chosen export must have finished: one still rendering is a 409 conflict that says so, rather
than a zip quietly holding fewer videos than were asked for. More than 400 MB together is a 413
payload_too_large; download them in smaller groups, or one at a time.
Poll
curl http://localhost:4000/v1/direct-export/video/01M2SQTF3R3GHBVTR3DQQNH1DK \
-H "Authorization: Bearer rw_your_key_here"
{ "status": "rendering", "progress": 0.1, "renderer": "render-1", "ahead": 0, "error": null, "kept": false, "keptNote": null }
{ "status": "succeeded", "progress": 1, "renderer": "render-1", "ahead": 0, "error": null, "kept": true, "keptNote": null }
status is queued, rendering, succeeded, failed, timed_out or cancelled. ahead is how
many clips are in front of yours while it is queued. A machine draws one clip at a time and takes
posts' clips as well as yours, so "rendering, 0%" for a minute would be a lie that reads as a stall.
kept says the finished video has been copied into the workspace's rendered clips, which happens the
moment anybody sees it finished, or shortly after if nobody is looking. It is then kept exactly as a
post's clip is: in the rendered clips' share of the storage, listed on
/v1/media/renders, and removed oldest first when newer clips need the room.
A clip larger than that whole share is not kept, and keptNote says so; it can still be downloaded
for two hours.
Download
A download is the .mp4 itself.
curl -o export.mp4 \
http://localhost:4000/v1/direct-export/video/01M2SQTF3R3GHBVTR3DQQNH1DK/download \
-H "Authorization: Bearer rw_your_key_here"
HTTP/1.1 200 OK
content-type: video/mp4
content-disposition: inline; filename="export-9x16-en.mp4"
A kept video is served with Range, so a player can start before the file has arrived and can seek.
?as=video, which used to ask for the video without a zip around it, is still accepted and changes
nothing: there is no zip any more.
A link to hand to a person
POST /v1/direct-export/video/{jobId}/link answers with an address that saves the .mp4 in a
browser, with no credential. It is what an assistant hands over, since a chat can carry an address
but not a video.
{
"url": "http://localhost:4000/v1/export-files/01M2SQTF3R3GHBVTR3DQQNH1DK?as=video&key=...",
"expiresAt": null,
"form": "video",
"kept": true
}
The link names that one file and nothing else. For a kept video it works for as long as the video is
kept, and expiresAt is null; otherwise it lasts fifteen minutes. Asking before the video has
finished is a 502 asset_unavailable that says what it is doing instead. Asking for a link counts a
finished video against the allowance exactly as polling would.
Every export so far
GET /v1/direct-export/videos lists the workspace's kept exports, newest first, with a link to each
that lasts as long as the video does. It takes limit, 24 by default and at most 100.
{
"exports": [
{
"jobId": "01M2SQTF3R3GHBVTR3DQQNH1DK",
"templateId": "tpl_a9c3fdb1b5c9",
"templateName": "Template 1",
"version": "1.0.0",
"ratio": "9x16",
"locale": "en",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"width": 1080,
"height": 1920,
"durationMs": 10000,
"byteSize": 742118,
"createdBy": "apikey:01M2SQKKZ3ZPXK8RB7BK5QJ3GH",
"createdAt": "2026-09-18T07:49:02.114Z",
"videoUrl": "http://localhost:4000/v1/export-files/01M2SQTF3R3GHBVTR3DQQNH1DK?as=video&key=..."
}
],
"kept": "Kept with the rendered clips, in their share of the storage. When it is full the oldest clip goes first."
}
GET /v1/direct-export/videos/{jobId} is one of them with the content it was drawn from, which is
where a variation starts.
Discard
curl -X POST \
http://localhost:4000/v1/direct-export/video/01M2SQTF3R3GHBVTR3DQQNH1DK/discard \
-H "Authorization: Bearer rw_your_key_here"
{ "discarded": true }
Discard stops a video still rendering and deletes the kept copy. It is safe to repeat: an id with
nothing left to discard answers { "discarded": false }. An export you want to keep needs no
discarding at all. It is what the dashboard's Cancel and Delete do under Exported videos; closing
Direct export never discards, and leaves every video rendering. A person needs write access to
Direct export for it.
What it costs
A video counts as one render against the month's allowance, charged when it finishes rather than when it starts: an export cancelled two seconds in is not one you received. Submission is still refused once nothing is left, so the allowance cannot be run past.
Keyframes, POST /v1/direct-export/keyframes, are single stills at frames you choose: the same body
as a video plus frames, 1 to 60 of { "name": "...", "frame": 120 }. They come back as PNGs in one
zip, each named after its keyframe, and are not counted against the allowance. A frame past the end
of the clip is a 400.
Export ids belong to a workspace
The renderer's job ids are global, so Reelwire remembers which workspace started which job. A job id from another workspace answers 404, exactly as one that never existed.
Mistakes people make
Polling the status route before you need to. Read frames from /timing and wait roughly that
long before the first poll.
Discarding what you meant to keep. Discard deletes the kept copy too. Leave an export alone and it stays until newer clips need the room.
Expecting the download to be JSON, or a zip. It is video/mp4.
Omitting version on an export. It is required. /v1/templates/{id}/reference lists the builds.
Buying allowance with an API key. It is the owner's, signed in. A key has no role, whatever its permissions.
Deleting a rendered clip that a post still needs. Every post of its job still waiting on it is cancelled with it. See Media library.