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, take the video away, and keep nothing. No render job row, no asset, no post, no channel.
| Method | Path | Permission |
|---|---|---|
GET | /v1/render-jobs | renders:read |
GET | /v1/render-jobs/{id}/package | renders:read |
GET | /v1/renders/quota | renders:read |
POST | /v1/direct-export/timing | renders:write |
POST | /v1/direct-export/video | 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}/discard | renders:write |
POST | /v1/renders/buy | renders:write |
POST | /v1/renders/keep | renders:write |
POST | /v1/renders/drop | renders:write |
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,
"unlimited": false,
"periodStart": "2026-09-06T13:50:50.821Z",
"periodEnd": "2026-10-06T13:50:50.821Z",
"overridden": false
},
"seats": { "used": 6, "max": 10, "unlimited": false },
"partners": { "used": 0, "max": 100, "unlimited": false },
"storage": {
"used": 26.9,
"max": 500,
"usedBytes": 28182585,
"maxBytes": 524288000,
"usedBy": { "media": 10821887, "renders": 17360698 },
"unlimited": false,
"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.
POST /v1/renders/buy, /keep and /drop change what the workspace is paying for, and are
owner-only: an API key has no role, so it is refused whatever its permissions.
{
"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"] }
}
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. 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
Four steps: ask how long the clip will be, start it, poll, download. Then discard 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 is a read: it needs renders:read and costs nothing.
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 checked against that build before anything is submitted, exactly as a post list's post is, so a body that does not fit is a 400 naming the field rather than a render that fails a minute later.
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 }
{ "status": "succeeded", "progress": 1, "renderer": "render-1", "ahead": 0, "error": null }
ahead is how many clips are in front of yours while status 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.
Download
A download is a zip, holding the video and a small HTML page that plays it. The page is
self-contained: it loads no stylesheet, font or script from anywhere, so it works on a machine with
no network and survives being forwarded to somebody outside your company. That matters because
double-clicking an .mp4 opens whatever that machine registered for video, while the .html opens
the browser, which is what drew the clip in the first place.
curl -o export.zip \
http://localhost:4000/v1/direct-export/video/01M2SQTF3R3GHBVTR3DQQNH1DK/download \
-H "Authorization: Bearer rw_your_key_here"
HTTP/1.1 200 OK
content-type: application/zip
content-disposition: attachment; filename="export-9x16-en.zip"
export-9x16-en.zip
├── export-9x16-en.mp4
└── export-9x16-en.html
Just the file
Add ?as=video when you want the video on its own, with no zip around it. A pipeline that hands the
bytes straight to something else has no use for a player, and this is also what the export screen
uses to play the clip in place, since a <video> tag cannot play a zip.
curl -o export.mp4 \
"http://localhost:4000/v1/direct-export/video/01M2SQTF3R3GHBVTR3DQQNH1DK/download?as=video" \
-H "Authorization: Bearer rw_your_key_here"
HTTP/1.1 200 OK
content-type: video/mp4
content-disposition: inline; filename="export-9x16-en.mp4"
Discard
curl -X POST \
http://localhost:4000/v1/direct-export/video/01M2SQTF3R3GHBVTR3DQQNH1DK/discard \
-H "Authorization: Bearer rw_your_key_here"
{ "discarded": true }
Discard when you have the file. The renderer keeps nothing afterwards, and an export nobody discards is forgotten after two hours anyway.
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. That refusal is a rate_limited,
and therefore a 429, with a message naming the allowance and the date it resets.
Keyframes, POST /v1/direct-export/keyframes, are single stills at frames you choose, up to 60 of
them in one archive. They are not counted against the allowance.
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.
Never discarding. The export holds a slot on the renderer until it is discarded or times out, which makes your own next export wait.
Expecting the download to be JSON. 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 owner-only. A key has no role, whatever its permissions.
Deleting a rendered clip that a post still needs. /v1/media/renders marks those needed: true
and refuses. See Media library.