Skip to main content

Renders and direct export

Every clip a post carries 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.

In the dashboard, direct export is Create clip in Clip studio, and its videos are listed under Exported videos there. Its permission area is Clip studio - Create clip (renders:read and renders:write); the API's routes and the MCP tools keep the name direct export.

MethodPathPermission
GET/v1/render-jobsrenders:read
GET/v1/render-jobs/{id}/packagerenders:read
GET/v1/renders/quotaallowance:read
POST/v1/templates/{templateId}/validatetemplates:read
POST/v1/direct-export/timingrenders:read
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
GET/v1/export-files/{jobId}none: the signed link itself
POST/v1/renders/buy, /v1/renders/keep, /v1/renders/dropsigned-in owner
POST/v1/storage/splitsigned-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 },
"connections": { "used": 1, "max": 3 },
"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, which is what the Allowance screen draws: sources, where each ceiling comes from (what the plan includes, what was bought and what Reelwire granted), addOns, what extras the workspace holds and what more would cost, and canBuy, whether the person asking may buy. What the extras cost (addOns.monthlyCents, addOns.monthlyBy and the prices in addOns.offers) is the owner's: a key without plan:read, and any colleague, gets null for both totals and no offers.

addOns.offers holds only what a workspace adds itself, priced for it: on Enterprise, at the prices agreed in its offer. Syndication participants are never among them. addOns.agreed names what the workspace holds but agrees with Reelwire rather than buys: ["partners"] on Enterprise, and empty on the plans below it, which hold no participants. More participants are a new offer, and POST /v1/renders/buy refuses a participant step with a 409. addOns.paysBy is CARD, where what is added is charged to the card at once, or INVOICE, where it is invoiced at once, payable within addOns.invoiceDays days.

connections counts the platform accounts connected through Zernio, the posting service, an account once however many channels post to it, against what the plan includes, what was bought and what Reelwire granted (see Channels). Free includes none.

storage is one allowance shared by the media library and the rendered clips. storage.split says how far the owner may move the line between the two right now, which is what POST /v1/storage/split does: see Media library.

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_dd7e6b264fb8",
"templateName": "Big number",
"templateVariant": "Corporate glass",
"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; the list pages, searches and sorts like every log (see Lists and paging).

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, which Enterprise has. clipsStored fewer than outputsSucceeded means clips were removed to make storage room. clip is the job's video while it is still stored, at an address signed for an hour, and null once it is gone.

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, check the content, 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_dd7e6b264fb8",
"version": "1.0.0",
"variant": "Corporate glass",
"data": { "clipData": { "label": "Ten-year Bund yield at the close", "value": 2.41, "unit": "%" } }
}'
{ "fps": 30, "frames": 300, "durationMs": 10000, "version": "1.0.0", "variant": "Corporate glass" }

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, so although it is a POST, a key that may read renders may ask it. version and variant say which build it timed: without them, the newest build of the template's first variant. It is the template's own length; a brand's intro and outro play around it in a finished clip.

Check the content first​

POST /v1/templates/{templateId}/validate takes variant, version and data and runs exactly the checks a video's content is held to before it is started, including whether every picture the content names (media://...) is in the workspace's media library. It renders nothing, stores nothing and costs nothing, so a key that may read templates may ask it, though it is a POST. It is Validate input in the Create clip dialogue.

curl http://localhost:4000/v1/templates/tpl_dd7e6b264fb8/validate \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"variant": "Corporate glass",
"data": { "clipData": { "headline": "Bund yields close at a three-week high", "value": 2.41 } }
}'
{
"ok": false,
"templateId": "tpl_dd7e6b264fb8",
"variant": "Corporate glass",
"version": "1.0.0",
"problems": [
{ "path": "/clipData", "message": "missing label" },
{ "path": "/clipData", "message": "has a field this template does not take: headline" }
]
}

Content that does not fit is an answer with ok: false, not an error: each problem names the field by its path and says what is wrong in words, such as the field missing, a field the template does not take, the values a choice allows, a text over its length, or a picture the library does not have. variant and version say what it was checked against: without them, the newest build of the template's first variant. A variant the template does not have is a 400 schema_validation naming the variants it has, and so is a version it does not have, or data that is not one object.

Start a video​

POST /v1/direct-export/video takes the same fields plus the look: brandId, ratio and locale. In place of brandId it takes style, a whole brand document as GET /v1/brands/{id} answers one, for a brand not saved yet; its files are drawn only where they are media:// references to files in the workspace's media library, and anything else in it is left out. Give one of the two, never both. The batch and keyframe routes take the same choice.

curl http://localhost:4000/v1/direct-export/video \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"templateId": "tpl_dd7e6b264fb8",
"version": "1.0.0",
"variant": "Corporate glass",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"ratio": "9x16",
"locale": "en",
"data": {
"clipData": {
"kicker": "Rates",
"label": "Ten-year Bund yield at the close",
"value": 2.41,
"unit": "%",
"window": "a three-week high",
"source": "Deutsche Finanzagentur"
},
"metaData": { "title": "Bund yields close at a three-week high" }
}
}'
{ "jobId": "01M2SQTF3R3GHBVTR3DQQNH1DK", "templateId": "tpl_dd7e6b264fb8", "variant": "Corporate glass", "version": "1.0.0" }

202. Without version (or with latest) the newest build of the variant is drawn, and without variant the template's first: the answer says which, and so do the kept export and its reuse. A variant the template does not have is refused with the ones it has, never drawn as another. 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.

The Background, Stinger, Intro and Outro templates make a brand's own films, which then become part of the brand, so their clips are always drawn on their own: without the brand's intro, outro, stinger or soundtrack around them and without its background under them, whatever the brand has set.

An export waits in the same line as the workspace's posts, in the order it was asked for: with priority rendering, which Enterprise has, both go ahead of standard work.

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 build to render with yet.
404 not_foundNo brand with that id in this workspace.
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, or it names a picture (media://...) the media library does not have; each detail points at the field, as the check above does. Also a variant the template does not have (the message names the ones it has), a version it does not have, and a body with both brandId and style, or neither.
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, a repeated shape or language counted once.

curl http://localhost:4000/v1/direct-export/batch \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"templateId": "tpl_dd7e6b264fb8",
"version": "1.0.0",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"ratios": ["9x16", "1x1"],
"locales": ["en", "de"],
"data": {
"clipData": { "label": "Ten-year Bund yield at the close", "value": 2.41, "unit": "%" },
"locales": { "de": { "clipData": { "label": "Rendite der zehnjährigen Bundesanleihe zum Schluss" } } }
}
}'
{
"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,
"variant": "Corporate glass",
"version": "1.0.0"
}

202. Each job is an ordinary export from then on: polled, downloaded, kept and charged on its own. variant and version say which build every one of them draws, resolved as for a single video. The content and every shape are checked, and the allowance is checked for all of them, counting exports still rendering, before the first is started, so a mistake refuses the whole batch with the same answers as a single video; a shape the template does not draw is named. More than 24 videos is a 400 schema_validation that says how many were asked for. 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_dd7e6b264fb8",
"templateName": "Big number",
"variant": "Corporate glass",
"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",
"preview": null
}
]
}

status, progress, ahead and keptNote mean what they mean when polling one video, below. preview, on a kept export, is a signed link to its video, good until the end of the next whole hour, which answers ranges, so a player or a thumbnail fetches only what it shows; null until it is kept. 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_dd7e6b264fb8",
"templateName": "Big number",
"variant": "Corporate glass",
"version": "1.0.0",
"pinnedVersion": "1.0.0",
"brandId": "01M1VFRWE7VHP3H54ESRJQ0SAJ",
"ratio": "9x16",
"locale": "en",
"data": { "clipData": { "label": "Ten-year Bund yield at the close", "value": 2.41, "unit": "%" } },
"title": "Copy of Big number",
"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.

It is the dashboard's Download selected. GET /v1/direct-export/zip/link?jobIds=a,b answers { url, expiresAt } for the same zip, a link that works for fifteen minutes with no credential, which is what an assistant hands over, since a chat can carry an address and not a file (reelwire_download_export with jobIds).

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, "keptId": null }
{ "status": "succeeded", "progress": 1, "renderer": "render-1", "ahead": 0, "error": null, "kept": true, "keptNote": null, "keptId": "01M2SR0B6K9TQ4V1X3HZJ8D2PA" }

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 renderer draws only a few clips at once 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. keptId is the kept copy's id, which POST /v1/media/renders/{id}/copy takes to put the video into the media library.

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.

Every clip Reelwire makes, a post's or an export, carries its own metadata, which a player such as VLC shows under its file information:

TagWhat it holds
TitleThe post's title (metaData.title in the content), else the template's name
ArtistThe brand's name, as the clip shows it
Copyright© <year> <brand>
Date and creation timeWhen the clip was made
Encoding toolReelwire
CommentMade with Reelwire, reelwire.io
LanguageThe clip's language, on its picture and sound

Nothing else: no other program's name, nothing about your workspace, channel or post list, and nothing that marks the clip as AI. ?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.

The link itself, GET /v1/export-files/{jobId}, needs no credential, because it is one: for exactly one file. One that was altered or has run out is a 403, and one to a video no longer kept a 404.

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_dd7e6b264fb8",
"templateName": "Big number",
"version": "1.0.0",
"variant": "Corporate glass",
"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."
}

variant is the cut it was drawn in: pass it on with a variation, or the variation draws the template's 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 the Create clip dialogue never discards, and leaves every video rendering. A key needs renders:write for it, and a signed-in person the Operations permission.

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. Leave frames out for the keyframe alone, a frame from the last third of the clip.

Two more fields make them a way to try things before anything is saved:

  • data left out draws the variant's own worked example, the content its Preview shows, so a style or a brand can be looked at before anybody has written a post.
  • brandChange, beside brandId, lays a change over that brand's style for these stills alone: objects merged at any depth, a list replaced whole, null taking a value away, and the colours worked out again from palette as a save would. The brand's own files stay as stored; a file the change names must be in the media library. Nothing is written. A change that does not make a valid style is a 400.
  • guides set to "platforms" draws, on the stills only and never in a video, translucent red boxes where TikTok, Instagram Reels and YouTube Shorts put their buttons, caption lines and progress bar on a 9x16 frame (a feed's or a player's controls on the other shapes), and the brand's safe area as a green outline, so you see whether anything that matters sits under them.
{
"templateId": "tpl_dd7e6b264fb8",
"brandId": "01JA...",
"ratio": "9x16",
"locale": "en",
"brandChange": { "palette": { "brand": "#1E5AA8", "stage": "light" }, "text": { "primary": { "font": "montserrat" } } }
}

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.

Reelwire keeps that record until the video is kept, so an update or a restart of Reelwire loses no export still rendering, and a video the renderer lost while it restarted is sent to it again under the same job id. A video already kept stays until it is deleted or makes room for newer clips.

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.

Leaving variant out of a variation. It is the template's first variant then, which for a vertical such as Finance is another post with other fields. A kept export, its reuse and the export's own answer all say which variant it was; pass it on.

Spending a render to find a mistake. POST /v1/templates/{templateId}/validate answers the same checks for nothing, missing pictures included.

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.