Skip to main content

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.

MethodPathPermission
GET/v1/render-jobsrenders:read
GET/v1/render-jobs/{id}/packagerenders:read
GET/v1/renders/quotaallowance:read
POST/v1/direct-export/timingrenders:write
POST/v1/direct-export/videorenders:write
POST/v1/direct-export/batchrenders:write
GET/v1/direct-export/queuerenders:read
GET/v1/direct-export/video/{jobId}/reuserenders:read
POST/v1/direct-export/ziprenders:write
GET/v1/direct-export/video/{jobId}renders:read
GET/v1/direct-export/video/{jobId}/downloadrenders:read
POST/v1/direct-export/video/{jobId}/linkrenders:write
POST/v1/direct-export/video/{jobId}/discardrenders:write
GET/v1/direct-export/videosrenders:read
GET/v1/direct-export/videos/{jobId}renders:read
POST/v1/direct-export/keyframesrenders:write
POST/v1/renders/buy, /v1/renders/keep, /v1/renders/dropsigned-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:

RefusalWhy
404 unknown_templateThe template is not offered to this workspace, or has no such version.
400 ratio_not_supportedThe template does not draw that shape.
400 schema_validationThe content does not fit the build, exactly as a post list's post is checked. Each detail points at the field.
429 rate_limitedNo videos are left this month, counting exports still rendering. The message names the allowance and the date it resets.
503 upstream_unavailableNo 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.

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.