On this page
Roll & Cut API
API documentation
Make films from your own code. Send a prompt, follow the production while it renders, and download the finished film — everything the app does, over HTTPS and JSON.
Productions run in the background. Creating one answers in a moment; the film itself takes minutes. We tell your server when it is done with a webhook, or you can ask as often as you like.
Every account can use the API. What it makes is paid for from the same credit balance as the app — see Credits.
https://rollandcutlabs.com/api/v1Quick start
Three steps from nothing to a finished film.
- 1
Create an API key
Open the API keys page, name a key and copy it — it is shown once. Keep it on your server; the examples here read it from an environment variable called
ROLL_AND_CUT_API_KEY. - 2
Start a production
POST /productionswith a prompt. Everything else is optional: a recipe sets the length, the shape, the genre and more, and leaving it out gets you our defaults. - 3
Find out when it is done
Add a webhook endpoint on the same API keys page and we will tell your server when the production completes, fails or stops for you. Or poll
GET /productions/:id— every 30 seconds or so is plenty. Oncestatusiscompleted,GET /productions/:id/assetshas the download links.
# 1. Start a production
curl https://rollandcutlabs.com/api/v1/productions \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "A short explainer about why the sky is blue"}'
# 2. Check on it, with the id from step 1
curl https://rollandcutlabs.com/api/v1/productions/PRODUCTION_ID \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
# 3. Once its status is "completed", get the download links
curl https://rollandcutlabs.com/api/v1/productions/PRODUCTION_ID/assets \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"Basics
Authentication
Send your API key with every request, as a bearer token: Authorization: Bearer ck_…. Keys are created and revoked on the API keys page when you are signed in.
A key authenticates as your account — the same credits, the same productions. Keep it secret: anyone holding it can spend your credits. Use it only from your own server, never from a browser or an app you ship to people. Creating a key needs a confirmed email address.
Keys start with ck_. The whole key is shown once, when you create it; we keep only a fingerprint of it, so a lost key cannot be recovered — create another and revoke the old one. Revoking works immediately. Each key has its own rate limit, so separate keys for testing and for live traffic can't crowd each other out.
Errors
- 401
unauthorizedNo Authorization header, or a key that is malformed, unknown or revoked.
curl https://rollandcutlabs.com/api/v1/account \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"error": {
"code": "unauthorized",
"message": "That API key is not valid."
}
}Requests and responses
Every endpoint lives under one base URL, https://rollandcutlabs.com/api/v1. Send request bodies as JSON with Content-Type: application/json; every answer is JSON.
- Ids are UUIDs.
- Times are ISO 8601 strings in UTC, such as
2026-09-29T15:04:05.000Z. - Credits are whole numbers. What a credit is worth is in your account (
credits.valueCents). - Successful answers carry the rate-limit headers and
Roll-And-Cut-Api-Version: v1. - New fields can appear in answers over time. Ignore any you don't recognize.
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 42
Roll-And-Cut-Api-Version: v1Rate limits
Each API key may make 120 requests a minute, counted separately for every key — GET /account returns the figure as limits.requestsPerMinute. The count resets at the start of every minute.
| Header | What it says |
|---|---|
X-RateLimit-Limit | Your requests per minute. |
X-RateLimit-Remaining | How many are left this minute. |
X-RateLimit-Reset | Seconds until the count resets. |
A request over the limit is refused with 429 rate_limited and a Retry-After header, in seconds. A production takes minutes, so poll gently — or let webhooks tell you.
HTTP/1.1 429 Too Many Requests
Retry-After: 18
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 18
Roll-And-Cut-Api-Version: v1{
"error": {
"code": "rate_limited",
"message": "Too many requests — each key allows 120 a minute. Retry in 18s."
}
}Idempotency
Networks fail, and a retried create without protection is a second film and a second charge. Send an Idempotency-Key header on Create a production — any unique string of up to 255 characters, such as your own order id.
- A retry with the same key and the same body gets the original answer back — the production as it was created — with
Idempotent-Replay: true, instead of starting a second one. - The same key with a different body is refused with
409 conflict. So is a retry that arrives while the first request is still being processed; try again shortly. - If the first request failed, the key is released, and a retry with it runs afresh.
- “The same body” means the same bytes: the same JSON written differently counts as a different body.
- Keys belong to your account. Only creating a production reads them.
HTTP/1.1 201 Created
Content-Type: application/json
Idempotent-Replay: true{
"error": {
"code": "conflict",
"message": "This `Idempotency-Key` was already used with a different request body."
}
}Errors
Every refusal has the same shape: { "error": { "code": "…", "message": "…" } }. Branch on code — the codes are a stable contract: new ones may be added, but a code's meaning never changes. message is written for people and its wording can change, so show it, but don't parse it. Some errors add details, a list of the specific reasons — a recipe we can't make is refused with invalid_request and every reason in details.
| Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | The request is malformed or can't be made: a body that isn't JSON, a missing or over-long prompt, an unknown or invalid recipe field, a recipe we can't make — details lists every reason — or a prompt our content check refused. |
unauthorized | 401 | No API key, or a key that is malformed, unknown or revoked. |
forbidden | 403 | Your account has been closed. |
email_unverified | 403 | Your email address isn't confirmed yet. Starting a production needs a confirmed address: open the link we emailed you, then try again. |
not_found | 404 | No production with that id on your account. Someone else's production answers the same way. |
conflict | 409 | The request clashes with how things stand: an idempotency key reused or still in flight, a production that has already stopped, or one that isn't waiting to be continued — a cut point included. |
insufficient_credits | 402 | Your balance can't cover what this needs to start, or to go on. |
rate_limited | 429 | Over this key's requests per minute. Wait Retry-After seconds. |
expired | 410 | The production's files have been deleted. |
film_language_unsupported | 422 | The prompt asks for a film in a language we don't make films in yet — language names it. Send the production again with filmLanguage set to one we do. |
internal_error | 500 | Something went wrong on our side. Safe to retry — with an Idempotency-Key when creating. |
Any endpoint can answer unauthorized, rate_limited or internal_error; each endpoint below lists the rest. A production that fails after it has started is not an error answer — the request that started it succeeded. It reports a failureCode on the production instead.
{
"error": {
"code": "invalid_request",
"message": "This recipe can't be made: \"example:not-a-model\" is not a model we offer.",
"details": [
"\"example:not-a-model\" is not a model we offer."
]
}
}Endpoints
Create a production
POST/api/v1/productions
Starts a production from a prompt and a recipe. It answers 201 as soon as the production is queued — the film takes minutes more, so follow it with webhooks or by polling Get a production.
Before anything is created, the prompt goes through our content check and the recipe is checked for anything we can't make — a length that isn't available yet, a model we don't offer, or two choices that don't work together. Then the credits for writing the script are reserved — see Credits.
The film is made in the prompt's language — the one it asks for, or else the one it is written in — unless filmLanguage names another. While our content check is unavailable, a prompt that isn't in English is answered with internal_error: send it again in a minute.
Headers
Idempotency-Keystring, up to 255 charactersBody
promptrequiredstringrecipeobjectdurationTierIdstringlength.filmLanguagestringen, es, fr, de, it, pt, nl, pl, ro, cs, sk, hu, el, bg, hr, sr, sl, sv, da, nb, fi, et, lv, lt, is, uk, ru, ca, hi, bn, mr, te, ta, gu, kn, ml, pa, or, ja, ko, zh-Hant, zh-Hans, id, ms, vi, th, fil, tr, ne, hy, ka, az, kk, ky or tg. Leave it out and we read it from the prompt — the language it asks for, or else the one it is written in; a prompt that shows no language of its own takes your account's.Returns
201 Created with { "production": … } — the production, its status queued, its language in filmLanguage, and the script's credits in reservedCredits.
Errors
- 400
invalid_requestThe body isn't JSON, the prompt is missing or longer than 20,000 characters, the recipe is invalid or asks for something we can't make (detailslists every reason),filmLanguageisn't a language code, or our content check refused the prompt. Nothing is created or charged. - 422
film_language_unsupportedThe prompt asks for a film in a language we don't make films in yet, orfilmLanguagenames one —languagesays which. Nothing is created or charged; send it again withfilmLanguageset to a language we do. - 402
insufficient_creditsYour balance can't cover the credits this production reserves to start. Nothing is created. - 403
email_unverifiedYour email address isn't confirmed yet. Nothing is created. - 403
forbiddenYour account has been closed. - 409
conflictTheIdempotency-Keywas used with a different body, or its first request is still being processed.
curl https://rollandcutlabs.com/api/v1/productions \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1234" \
-d '{
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"recipe": {
"length": "30_60s",
"aspectRatio": "9:16",
"genre": {
"mode": "pinned",
"styleId": "nature-science-documentary"
}
}
}'{
"production": {
"id": "5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35",
"status": "queued",
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"length": "30_60s",
"aspectRatio": "9:16",
"look": "motion",
"resolution": "720p",
"filmLanguage": "en",
"progressPercent": 0,
"currentStage": null,
"reviewStage": null,
"title": null,
"shotCount": null,
"failureCode": null,
"reservedCredits": 143,
"spentCredits": null,
"chargedCredits": null,
"filesExpireAt": null,
"filesDeletedAt": null,
"createdAt": "2026-09-29T15:04:05.000Z",
"updatedAt": "2026-09-29T15:04:05.000Z"
}
}Estimate a production
POST/api/v1/productions/estimate
Prices a request before you spend anything — nothing is created, reserved or charged. Send what you would send to Create a production. You get two kinds of number, because they answer different questions:
reserveCredits— what creating it would take from your balance up front: the credits for writing the script. Whether your balance lets you start depends on this alone, andwallet.sufficientsays whether it does.bandLowCreditstobandHighCredits— our modeled estimate for the whole production, the range to quote your own users. An estimate, not a cap: most productions land inside it, and going past it neither pauses nor warns.wallet.coversEstimatesays whether your balance reaches the top of it — if it doesn't, the production can still start, and may pause partway for a top-up, losing nothing.
Each has a …Usd twin, a display string such as $1.43. recipe is your recipe as it would run, with every default filled in.
Body
recipeobjectpromptstringdurationTierIdstringlength.Returns
200 OK with estimate and wallet (balanceCredits, sufficient, coversEstimate).
Errors
- 400
invalid_requestThe body isn't JSON, the prompt is over 20,000 characters, the recipe is invalid, or it asks for something we can't make —detailslists every reason. No price is quoted for a production that would be refused.
curl https://rollandcutlabs.com/api/v1/productions/estimate \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"recipe": {
"length": "30_60s",
"aspectRatio": "9:16",
"genre": {
"mode": "pinned",
"styleId": "nature-science-documentary"
}
}
}'{
"estimate": {
"reserveCredits": 143,
"reserveUsd": "$1.43",
"bandLowCredits": 800,
"bandLowUsd": "$8.00",
"bandHighCredits": 933,
"bandHighUsd": "$9.33",
"recipe": {
"length": "30_60s",
"aspectRatio": "9:16",
"genre": {
"mode": "pinned",
"styleId": "nature-science-documentary"
},
"voices": [],
"crew": {},
"models": {},
"audio": {
"music": true,
"narration": true
},
"reviewGates": {
"script": false,
"cast": false,
"shots": false
},
"lipSync": true,
"talkingCharacters": false,
"libraryFootage": false,
"libraryArchivalPeople": false,
"look": "motion",
"resolution": "720p",
"splitStems": true
},
"note": "Only `reserveCredits` is held up front. The band is a modeled estimate, not a cap: the production reserves per stage as facts arrive (the real shot list, then the measured narration), and it PAUSES at a stage boundary if the wallet cannot fund the next stage rather than overdrawing. The final charge is settled from measured provider cost and any excess reservation is refunded."
},
"wallet": {
"balanceCredits": 18450,
"sufficient": true,
"coversEstimate": true
}
}List productions
GET/api/v1/productions
Your productions, newest first, a page at a time. Each is a summary — what you need to follow its status and its money. For aspectRatio, reviewStage and the file dates, get the production itself.
Query parameters
limitintegeroffsetintegerReturns
200 OK with productions, the limit and offset used, and hasMore — true when there is another page: ask again with offset plus limit.
curl "https://rollandcutlabs.com/api/v1/productions?limit=20&offset=0" \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"productions": [
{
"id": "5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35",
"status": "in_progress",
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"length": "30_60s",
"progressPercent": 64,
"currentStage": "animate",
"title": "Hunters of the Night Reef",
"shotCount": 12,
"failureCode": null,
"reservedCredits": 303,
"spentCredits": 477,
"chargedCredits": null,
"createdAt": "2026-09-29T15:04:05.000Z",
"updatedAt": "2026-09-29T15:12:31.000Z"
},
{
"id": "c41f9e07-2a6b-4c85-9d10-6e2b8f5a1c94",
"status": "completed",
"prompt": "A two-minute history of the printing press, from Gutenberg to the newspaper",
"length": "1_3min",
"progressPercent": 100,
"currentStage": null,
"title": "The Press That Changed Everything",
"shotCount": 31,
"failureCode": null,
"reservedCredits": 0,
"spentCredits": 4118,
"chargedCredits": 4118,
"createdAt": "2026-09-27T10:41:18.000Z",
"updatedAt": "2026-09-27T11:02:55.000Z"
}
],
"hasMore": false,
"limit": 20,
"offset": 0
}Get a production
GET/api/v1/productions/:id
Where a production stands right now — its status, progress and money. This is the endpoint to poll, and the source of truth: if a webhook is ever lost, the answer is still here. While the production waits at a cut point, reviewStage says which one.
Path parameters
idrequiredstringReturns
200 OK with { "production": … } — the production.
Errors
- 404
not_foundNo production with that id on your account.
curl https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35 \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"production": {
"id": "5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35",
"status": "in_progress",
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"length": "30_60s",
"aspectRatio": "9:16",
"look": "motion",
"resolution": "720p",
"filmLanguage": "en",
"progressPercent": 64,
"currentStage": "animate",
"reviewStage": null,
"title": "Hunters of the Night Reef",
"shotCount": 12,
"failureCode": null,
"reservedCredits": 303,
"spentCredits": 477,
"chargedCredits": null,
"filesExpireAt": null,
"filesDeletedAt": null,
"createdAt": "2026-09-29T15:04:05.000Z",
"updatedAt": "2026-09-29T15:12:31.000Z"
}
}Get the files
GET/api/v1/productions/:id/assets
Download links for everything the production has made that is ready: the finished film, its separate tracks, its caption files and the storyboard pictures.
| kind | What it is |
|---|---|
final | The finished piece, audio mixed in. An MP4. |
final_clean | The same film with no burned-in text — pair it with a caption file. Only when subtitles were on. |
stem_video | The picture with no audio, for scoring it yourself. |
stem_narration | Every spoken line at its real offset, full length. |
stem_music | The music bed on its own. |
stem_world | The sound of each place on its own — the room and the world under every scene. |
caption_file | A caption file, when subtitles were on — one SRT and one WebVTT for every language delivered. refKey names it: en:srt, es-419:vtt. |
keyframe | A storyboard picture — one per shot, numbered by beatIndex from 0. |
character_ref, location_ref, prop_ref | Reference pictures of the cast, places and objects, where the production has them — named by refKey. |
Every finished film comes with its separate stem_… tracks — only the tracks it has: a film with no narration has no narration track.
Path parameters
idrequiredstringReturns
200 OK with assets — the files with no beatIndex first, then the storyboard pictures in shot order. Each has:
kindstringbeatIndexinteger or nullrefKeystring or nullen:srt. Otherwise null.contentTypestringvideo/mp4 or image/png.sizeBytesinteger or nullexpiresAtstring or nullfilesExpireAt. Null while it runs.urlstringErrors
- 404
not_foundNo production with that id on your account. - 410
expiredIts files have been deleted — see How long files are kept. The message gives the date.
curl https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35/assets \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"assets": [
{
"kind": "final",
"beatIndex": null,
"refKey": null,
"contentType": "video/mp4",
"sizeBytes": 38412907,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/final.mp4?token=…"
},
{
"kind": "final_clean",
"beatIndex": null,
"refKey": null,
"contentType": "video/mp4",
"sizeBytes": 38107233,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/final_clean.mp4?token=…"
},
{
"kind": "stem_video",
"beatIndex": null,
"refKey": null,
"contentType": "video/mp4",
"sizeBytes": 35170266,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/stem_video.mp4?token=…"
},
{
"kind": "stem_narration",
"beatIndex": null,
"refKey": null,
"contentType": "audio/mp4",
"sizeBytes": 742118,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/stem_narration.m4a?token=…"
},
{
"kind": "stem_music",
"beatIndex": null,
"refKey": null,
"contentType": "audio/mp4",
"sizeBytes": 1083392,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/stem_music.m4a?token=…"
},
{
"kind": "caption_file",
"beatIndex": null,
"refKey": "en:srt",
"contentType": "application/x-subrip",
"sizeBytes": 2214,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/captions.en.srt?token=…"
},
{
"kind": "caption_file",
"beatIndex": null,
"refKey": "en:vtt",
"contentType": "text/vtt",
"sizeBytes": 2187,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/captions.en.vtt?token=…"
},
{
"kind": "keyframe",
"beatIndex": 0,
"refKey": null,
"contentType": "image/png",
"sizeBytes": 2291764,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/keyframe-0.png?token=…"
},
{
"kind": "keyframe",
"beatIndex": 1,
"refKey": null,
"contentType": "image/png",
"sizeBytes": 2187450,
"expiresAt": "2026-10-13T15:19:42.000Z",
"url": "https://…/keyframe-1.png?token=…"
}
]
}Cancel a production
POST/api/v1/productions/:id/cancel
Asks a production to stop. A step that is already running finishes first, so this answers 202 Accepted with an outcome:
stopped— nothing was running (it was queued or paused), so it stops before doing any more work.stopping— work is under way; it stops at the next step.
Either way you pay for the work already done and the rest of what was held is refunded. status becomes cancelled, and production.cancelled is sent, once it has actually stopped. A production that finishes first stays completed — a finished film is yours. note says the same in words.
Path parameters
idrequiredstringReturns
202 Accepted with production (as it was when the request was recorded), outcome and note.
Errors
- 404
not_foundNo production with that id on your account. - 409
conflictIt has already stopped: it is completed, failed, canceled or expired.
curl -X POST https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35/cancel \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"production": {
"id": "5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35",
"status": "in_progress",
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"length": "30_60s",
"aspectRatio": "9:16",
"look": "motion",
"resolution": "720p",
"filmLanguage": "en",
"progressPercent": 64,
"currentStage": "animate",
"reviewStage": null,
"title": "Hunters of the Night Reef",
"shotCount": 12,
"failureCode": null,
"reservedCredits": 303,
"spentCredits": 477,
"chargedCredits": null,
"filesExpireAt": null,
"filesDeletedAt": null,
"createdAt": "2026-09-29T15:04:05.000Z",
"updatedAt": "2026-09-29T15:12:31.000Z"
},
"outcome": "stopping",
"note": "A render is in progress; it will stop at the next stage boundary. You are charged for work already completed."
}Continue a production
POST/api/v1/productions/:id/continue
Sets a stopped production moving again, without paying twice for anything it has already made. Use it after a production paused because your credits ran out (status insufficient_credits) and you have topped up.
It first checks that your balance, plus anything the production still holds, covers the next step, then queues it. It holds nothing itself: each step holds its own credits when it starts, and the production's reservedCredits shows what is held.
awaiting_review) is answered on its page in the app. Continuing one is refused with conflict.Path parameters
idrequiredstringReturns
202 Accepted with production — its status now queued — and note.
Errors
- 404
not_foundNo production with that id on your account. - 409
conflictIt isn't waiting to be continued, it is waiting at a cut point, it has no script yet, or its files have been deleted. - 402
insufficient_creditsYour balance, plus what it still holds, doesn't cover the next step — the message says how many more you need. - 403
forbiddenYour account has been closed.
curl -X POST https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35/continue \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"production": {
"id": "5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35",
"status": "queued",
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"length": "30_60s",
"aspectRatio": "9:16",
"look": "motion",
"resolution": "720p",
"filmLanguage": "en",
"progressPercent": 38,
"currentStage": null,
"reviewStage": null,
"title": "Hunters of the Night Reef",
"shotCount": 12,
"failureCode": null,
"reservedCredits": 0,
"spentCredits": 260,
"chargedCredits": null,
"filesExpireAt": null,
"filesDeletedAt": null,
"createdAt": "2026-09-29T15:04:05.000Z",
"updatedAt": "2026-09-29T16:02:10.000Z"
},
"note": "Queued to go on. Nothing is held by this call: each remaining step holds its own credits when it starts, and work already completed is not billed again."
}List styles
GET/api/v1/styles
The genre library: every genre you can pin, with its id, name, whenToUse and its full spec — medium, look, camera, pacing, shot lengths, narration, music, sound, cast, structure and tone. The spec is the same definition every step of a production works to, so you can choose with what we know.
spec.narrationMode says who speaks: narrator-voiceover (a narrator carries the film), character-dialogue (the characters talk), mixed (both), or sparse — pictures and music carry the film, with a few spoken lines at most, or none. A prompt that asks for a voice gets one, sparingly; a prompt that asks for no talking gets none. none (no spoken words) is still a valid value, though no genre uses it today.
lengths rides along: every length a recipe can name, with its label, targetSeconds and maxSeconds. Use a style's id as the recipe's genre.styleId — see Genre.
Returns
200 OK with styles, note and lengths.
curl https://rollandcutlabs.com/api/v1/styles \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"styles": [
{
"id": "nature-science-documentary",
"name": "Nature & science documentary",
"whenToUse": "Narrated real-world storytelling about nature, science and people — crisp and true to life — including real researched figures shown as on-screen charts.",
"spec": {
"visualMedium": "live-action",
"visualStyle": "Naturalistic cinematic grade; rich, true-to-life palette; depth, texture, and scale.",
"cameraStyle": "Composed, steady shots; slow push-ins and drifts; establish wide, then detail.",
"pacing": "measured",
"beatSecondsLow": 5,
"beatSecondsHigh": 10,
"narrationMode": "narrator-voiceover",
"voiceTone": "Warm, authoritative, unhurried narrator.",
"musicPresence": "underscore",
"musicStyle": "Orchestral or ambient underscore; emotional but restrained.",
"sfxDensity": "none",
"soundDesign": "Location ambience prominent; no discrete sound effects (they read as corny here).",
"characters": "none",
"structure": "multi-scene",
"emotionalRegister": "Reflective, awe, gravitas."
}
}
],
"note": "Use these ids with the recipe's `genre` field: {\"mode\":\"pinned\",\"styleId\":\"<id>\"} to fix the genre, {\"mode\":\"auto\"} (the default) to let the screenwriter choose from this list, or {\"mode\":\"custom\"} to have it compose a style for your piece instead. Every production commits to a genre — there is no mode that skips it.",
"lengths": [
{
"id": "under_30s",
"label": "Under 30 seconds",
"targetSeconds": 25,
"maxSeconds": 30
},
{
"id": "30_60s",
"label": "30–60 seconds",
"targetSeconds": 45,
"maxSeconds": 60
},
{
"id": "1_3min",
"label": "1–3 minutes",
"targetSeconds": 120,
"maxSeconds": 180
},
{
"id": "3_5min",
"label": "3–5 minutes",
"targetSeconds": 240,
"maxSeconds": 300
},
{
"id": "5_10min",
"label": "5–10 minutes",
"targetSeconds": 450,
"maxSeconds": 600
},
{
"id": "10_15min",
"label": "10–15 minutes",
"targetSeconds": 750,
"maxSeconds": 900
},
{
"id": "15_20min",
"label": "15–20 minutes",
"targetSeconds": 1050,
"maxSeconds": 1200
},
{
"id": "20_25min",
"label": "20–25 minutes",
"targetSeconds": 1350,
"maxSeconds": 1500
},
{
"id": "25_30min",
"label": "25–30 minutes",
"targetSeconds": 1650,
"maxSeconds": 1800
},
{
"id": "30_35min",
"label": "30–35 minutes",
"targetSeconds": 1950,
"maxSeconds": 2100
},
{
"id": "35_40min",
"label": "35–40 minutes",
"targetSeconds": 2250,
"maxSeconds": 2400
}
]
}List models
GET/api/v1/models
Everything a recipe can choose by id — every id here is one the recipe takes:
models— the models you can pick, each with itsid, theroleit fills (writersRoom,cameraormotion), anameand adescription. Put theidin the recipe'screw, under itsrole. A camera or motion model also hasresolutions: the film resolutions it makes — naming one for a film it can't make is refused.profiles— whole crews we put together, each with the model it puts in every seat. Use itsidascrew.profileId.voices— the voices a film can be cast from, each with itsid,name,description,gender,age,accentand the filmlanguagesit speaks. Put ids in the recipe'svoicesto cast only from those.
Returns
200 OK with models, profiles, voices and note.
curl https://rollandcutlabs.com/api/v1/models \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"models": [
{
"id": "openai:gpt-6.1-sol",
"role": "writersRoom",
"name": "GPT-6.1 Sol",
"description": "The default writer: OpenAI's newest family, close to its top model's quality."
},
{
"id": "openai:gpt-image-2.5",
"role": "camera",
"name": "GPT Image 2.5 High",
"description": "OpenAI's newest picture model at its sharpest — the default.",
"resolutions": [
"720p",
"1080p",
"1440p",
"4k"
]
},
{
"id": "runway:gen4_turbo",
"role": "motion",
"name": "Runway Gen-4 Turbo",
"description": "The default: fast, and the cheapest here.",
"resolutions": [
"720p"
]
}
],
"profiles": [
{
"id": "economy",
"name": "Economy",
"description": "Cheaper across the board. Best for drafts and for finding out what a brief becomes.",
"crew": {
"writersRoom": "google:gemini-3.5-flash-lite",
"camera": "openai:gpt-image-2.5-medium",
"motion": "runway:gen4_turbo"
}
}
],
"voices": [
{
"id": "elevenlabs:eGSd0uV8dNQnZuEEX9lu",
"name": "Adam",
"description": "Dominant, firm",
"gender": "masculine",
"age": "adult",
"accent": "american",
"languages": [
"en",
"es",
"fr",
"de",
"it",
"pt",
"nl",
"pl",
"ro",
"cs",
"sk",
"hu",
"el",
"bg",
"hr",
"sr",
"sl",
"sv",
"da",
"nb",
"fi",
"et",
"lv",
"lt",
"is",
"uk",
"ru",
"ca",
"hi",
"bn",
"mr",
"te",
"ta",
"gu",
"kn",
"ml",
"pa",
"or",
"ja",
"ko",
"zh-Hant",
"zh-Hans",
"id",
"ms",
"vi",
"th",
"fil",
"tr",
"ne",
"hy",
"ka",
"az",
"kk",
"ky",
"tg"
]
},
{
"id": "elevenlabs:BFd5oBc2DDna33pSi4Gf",
"name": "Alicia",
"description": "Polished global anchor",
"gender": "feminine",
"age": "adult",
"accent": "british",
"languages": [
"en",
"es",
"fr",
"de",
"it",
"pt",
"nl",
"pl",
"ro",
"cs",
"sk",
"hu",
"el",
"bg",
"hr",
"sr",
"sl",
"sv",
"da",
"nb",
"fi",
"et",
"lv",
"lt",
"is",
"uk",
"ru",
"ca",
"hi",
"bn",
"mr",
"te",
"ta",
"gu",
"kn",
"ml",
"pa",
"or",
"ja",
"ko",
"zh-Hant",
"zh-Hans",
"id",
"ms",
"vi",
"th",
"fil",
"tr",
"ne",
"hy",
"ka",
"az",
"kk",
"ky",
"tg"
]
}
],
"note": "Choose models with the recipe's `crew`: a model's `id` in the seat its `role` names (`crew.writersRoom`, `crew.camera` or `crew.motion`), or a whole crew we assembled with `crew.profileId`. Voice ids go in `voices` — the voices a film may be cast from. Anything you leave out takes our standard choice."
}Your account
GET/api/v1/account
Your credits, and the limits every account works within.
credits:availableis what a new production can draw on now;heldis reserved by the productions still running —heldRunsof them — and whatever a run doesn't use comes back when it settles;valueCentsis what one credit is worth, in US cents; andavailableUsdisavailableas a display string.limits:requestsPerMinutefor each key, how manywebhookEndpointsan account can add, everylengtha recipe can name (lengths) and the longest prompt, in characters (promptMaxChars).account.emailVerified— whether your email address is confirmed. Starting a production needs it.
Returns
200 OK with credits, limits and account.
curl https://rollandcutlabs.com/api/v1/account \
-H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"{
"credits": {
"available": 18450,
"held": 303,
"heldRuns": 1,
"valueCents": 1,
"availableUsd": "$184.50"
},
"limits": {
"requestsPerMinute": 120,
"webhookEndpoints": 5,
"lengths": [
"under_30s",
"30_60s",
"1_3min",
"3_5min",
"5_10min",
"10_15min",
"15_20min",
"20_25min",
"25_30min",
"30_35min",
"35_40min"
],
"promptMaxChars": 20000
},
"account": {
"emailVerified": true
}
}Reference
The production object
Every endpoint and every webhook that describes a production uses this one shape.
idstringstatusstringpromptstringlengthstringlength.aspectRatiostring16:9, 9:16 or 1:1.lookstringstills (Moving stills), mixed (Mixed) or motion (Full motion) — see the recipe's look.motionAmountstringtouch, some or lots — see the recipe's motionAmount.resolutionstring720p, 1080p, 1440p or 4k.filmLanguagestringprogressPercentintegercurrentStagestring or nullscript, generate_images, generate_voice, animate, generate_music or assemble_video. Null between steps, and always null once the production has stopped. Show it as progress; don't build logic on it.reviewStagestring or nulltitlestring or nullshotCountinteger or nullfailureCodestring or nullreservedCreditsinteger or nullspentCreditsinteger or nullchargedCreditsinteger or nullfilesExpireAtstring or nullfilesDeletedAtstring or nullcreatedAtstringupdatedAtstringStatuses
| status | Meaning |
|---|---|
queued | Accepted, and waiting to start — or to resume. |
in_progress | Running. |
awaiting_review | Paused at a cut point until you answer it in the app — reviewStage says which. Nothing is spent while it waits. |
insufficient_credits | Paused because the next step needs more credits than you have. Top up, then continue it. |
completed | Finished. Get the files. |
failed | Stopped by a problem — failureCode says which. Refunded in full. |
cancelled | Stopped at your request. You pay for the work already done; the rest is refunded. |
expired | A pause nobody came back to within 14 days. It is settled to what was actually made, the rest is refunded, and its files are deleted that day. The production itself — its prompt, settings and cost — can still be read. |
awaiting_review and insufficient_credits are pauses: whatever is held stays held, and after 14 days unanswered the production expires — settled to what was made, the rest of the hold refunded, and its files deleted that day. completed, failed, cancelled and expired are final.
Cut points
A cut point is a place a production stops, shows you what it has made so far, and waits for you before going on — turned on with the recipe's reviewGates, or opening on its own when something needs your decision. reviewStage names it:
| reviewStage | Cut point | What it is for |
|---|---|---|
script | Script | Read the script before anything is drawn. Turned on with reviewGates.script. |
cast | Cast | Check your cast before the storyboard is drawn. Turned on with reviewGates.cast. |
shots | Storyboard | Check every shot's picture before any motion is paid for. Turned on with reviewGates.shots. |
clips | Shots that couldn't be animated | Opens on its own when some shots couldn't be animated, so you can decide what happens to them. |
pictures | Pictures that couldn't be drawn | Opens on its own when a picture couldn't be drawn. |
Cut points are answered on the production's page in the app. The API reports them; it doesn't answer them.
Failure codes
| failureCode | Meaning |
|---|---|
generation_failed | One of the creative steps didn't return a usable result. Running it again usually works. |
content_rejected | Part of it didn't pass content safety checks. Reword the prompt and run it again. |
provider_unavailable | One of our creative services was temporarily unavailable. Try again shortly. |
insufficient_credits | Your credits ran out partway through. Top up and start it again. |
output_too_large | The finished film came out too large to store. The same request will do the same again — try a shorter length. |
internal_error | Something went wrong on our side. Try again. |
A failed production is refunded in full.
{
"production": {
"id": "5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35",
"status": "completed",
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"length": "30_60s",
"aspectRatio": "9:16",
"look": "motion",
"resolution": "720p",
"filmLanguage": "en",
"progressPercent": 100,
"currentStage": null,
"reviewStage": null,
"title": "Hunters of the Night Reef",
"shotCount": 12,
"failureCode": null,
"reservedCredits": 0,
"spentCredits": 867,
"chargedCredits": 867,
"filesExpireAt": "2026-10-13T15:19:42.000Z",
"filesDeletedAt": null,
"createdAt": "2026-09-29T15:04:05.000Z",
"updatedAt": "2026-09-29T15:19:42.000Z"
}
}The recipe
A recipe says how to make the film. Every field is optional; anything you leave out takes the default shown, and every value is open to every account. A recipe that asks for something we can't make — a length that isn't available yet, a model we don't offer, or two choices that don't work together — is refused with invalid_request, every reason in details; price it with the estimate first, which runs the same check. A field the recipe doesn't have, or a value it doesn't accept — inside subtitles and multiModel too — is refused with invalid_request as well, each problem in details, naming the field. The one exception: three fields we retired on September 30, 2026 — retakes, aiJudgeFeedback and crew.reviewer — are still accepted and ignored, so an integration written before then keeps working: every picture is now made once, and you ask for a change at a cut point.
lengthstringunder_30s, 30_60s, 1_3min, 3_5min, 5_10min, 10_15min, 15_20min, 20_25min, 25_30min, 30_35min or 35_40min (Under 30 seconds, 30–60 seconds, 1–3 minutes, 3–5 minutes, 5–10 minutes, 10–15 minutes, 15–20 minutes, 20–25 minutes, 25–30 minutes, 30–35 minutes, 35–40 minutes). Default "under_30s".aspectRatiostring16:9, 9:16 or 1:1. Default "16:9".lookstringstills — Moving stills: every shot is a still picture with a slow, smooth camera move, at a fraction of the price; nobody talks on camera, so characters' lines are heard over the pictures. motion — Full motion: every shot is a video clip, and a character who speaks on camera is filmed speaking, lips matching the words. mixed — Mixed: the writers' room makes each shot a moving still or a video clip, as much motion as motionAmount asks for, and on-camera dialogue is filmed. Default: motion, what every production was before the field existed.motionAmountstringmixed film moves: touch (about a tenth of its running time), some (about a third) or lots (more than half). The writers' room writes to it; the share delivered is measured, never forced. Default: touch. Ignored by the other looks.resolutionstring720p, 1080p, 1440p or 4k. Moving stills go from 720p to 4k (default 1080p); Mixed and Full motion from 720p to 1080p (default 720p). Every resolution is the real one — pictures and video are made at it, never enlarged to it; archive footage alone plays at the resolution it was filmed in. A resolution the look does not offer, or a crew.camera or crew.motion you name that cannot make it, is refused with invalid_request, each reason in details — for example {"look":"motion","resolution":"4k"}. Above 720p a character speaks on camera only in an ElevenLabs voice that can speak the film's language there; anyone else's lines are heard off camera — never refused. A higher resolution costs more: its pictures and video cost more, and so does the work to finish and keep it.genreobject{"mode":"auto"}.audioobjectmusic and narration, each a boolean. Turning one off leaves that track out, whatever the genre would do. The picture is always made. Default {"music":true,"narration":true}.reviewGatesobjectscript, cast and shots, each a boolean: stop at that cut point until you answer it in the app. Default {"script":false,"cast":false,"shots":false}.subtitlesobjectlibraryFootagebooleanfalse.libraryArchivalPeoplebooleanlibraryFootage on. Default false.multiModelobject{ "budget": … } — low, medium, high or max: we choose the models shot by shot, within that budget. Leave it out for our standard models.voicesarray of strings[].crewobjectprofileId, or model ids for writersRoom, camera and motion. Empty means our standard crew. List models has the ids. Default {}.modelsobjectscriptModel, image, script, voice, shot or music) is refused with invalid_request, naming the field. Choose models with crew; List models has the ids. Default {}.Two more fields, maxScriptAttempts and splitStems, are ours to set: whatever you send there is replaced. Every finished film comes with its separate tracks.
Subtitles
subtitles burns subtitles into the film. Every field is optional once you send the object; it is the enabled flag that turns them on.
enabledbooleanfalse.modestringfull_line, read_along, highlight_box, highlight_color, word_build or single_word. Every mode but full_line follows the spoken words one by one, so it needs language to be the film's own, film (or en). Where the film's words can't be followed one by one — subtitles in another language, or a film in a language we can't time word by word — the film shows a full line at a time. Default "full_line".stylestringshadow or box. Default "shadow".sizestringsmall, medium or large. Default "medium".accentstringamber, sky, green, pink, violet or white. Default "amber".animationstringfade, pop, rise or none. Default "none".languagestringfilm for the film's own language, whichever it is made in, or a code from the list below. Default "film".captionLanguagesarray of stringscaption_file files in Get the files, and on the production's page in the app. The burned-in language always comes as caption files too. Default [].The 60 language codes
- enEnglish
- es-419Spanish (Latin America)
- pt-BRPortuguese (Brazil)
- fr-CAFrench (Canada)
- es-ESSpanish (Spain)
- pt-PTPortuguese (Portugal)
- fr-FRFrench (France)
- deGerman
- itItalian
- nlDutch
- caCatalan
- elGreek
- daDanish
- svSwedish
- noNorwegian
- fiFinnish
- isIcelandic
- etEstonian
- lvLatvian
- ltLithuanian
- plPolish
- csCzech
- skSlovak
- huHungarian
- roRomanian
- hrCroatian
- srSerbian
- slSlovenian
- sqAlbanian
- bgBulgarian
- ruRussian
- ukUkrainian
- trTurkish
- hyArmenian
- kaGeorgian
- azAzerbaijani
- kkKazakh
- kyKyrgyz
- tgTajik
- hiHindi
- bnBangla
- mrMarathi
- teTelugu
- taTamil
- guGujarati
- knKannada
- mlMalayalam
- paPunjabi
- orOdia
- neNepali
- jaJapanese
- koKorean
- zh-HansChinese (Simplified)
- zh-HantChinese (Traditional)
- viVietnamese
- idIndonesian
- msMalay
- filFilipino
- thThai
- loLao
{
"length": "1_3min",
"aspectRatio": "16:9",
"genre": {
"mode": "pinned",
"styleId": "history-documentary"
},
"audio": {
"music": true,
"narration": true
},
"reviewGates": {
"script": true,
"cast": false,
"shots": true
},
"subtitles": {
"enabled": true,
"mode": "full_line",
"style": "shadow",
"size": "medium",
"language": "en",
"captionLanguages": [
"en",
"es-419"
]
},
"libraryFootage": true,
"libraryArchivalPeople": true,
"multiModel": {
"budget": "medium"
}
}Genre
genre decides how the style library is used. It is a tagged object rather than a plain id, so that an incomplete choice cannot be expressed:
{"mode":"auto"}— the default, and the right answer nearly always. The screenwriter reads your prompt and picks whichever genre fits best. A thin prompt is exactly when this matters most: committing to one clear genre is what makes a vague request into a coherent film.{"mode":"pinned","styleId":"…"}— that genre is used, whatever the prompt says.GET /styleshas the ids and their full specs.{"mode":"custom"}— no house genre. The screenwriter composes a style for this piece specifically, from your prompt, so describe the look you want in the prompt text.
A genre is a complete specification — medium, pacing, shot length, narration mode, music presence, sound design, cast — and every later stage works to it, so pinning one changes the whole production rather than tinting it. Every production commits to a genre; there is no mode that skips it, because a piece made in no particular style is a set of unrelated clips. An unknown styleId, a pinned with no id, or a custom carrying one are all refused as invalid_request — as is a pinned genre alongside a crew.motion model that can't shoot its shot lengths.
The genres you can pin
| styleId | Genre |
|---|---|
nature-science-documentary | Nature & science documentary |
animated-explainer | Animated explainer |
spec-commercial | Commercial |
quick-facts | Quick facts |
animated-story | Animated story |
drama-film | Drama film |
comedy-sketch | Comedy sketch |
movie-trailer | Movie trailer |
relaxation-nature | Relaxation & nature |
history-documentary | History documentary |
mystery-true-crime | Mystery & true crime |
horror-story | Horror story |
money-business-explainer | Money & business explainer |
motivation-philosophy | Motivation & philosophy |
sci-fi-film | Sci-fi film |
fantasy-film | Fantasy film |
anime-inspired | Anime-inspired |
countdown-list | Countdown list |
{ "mode": "auto" }
{ "mode": "pinned", "styleId": "nature-science-documentary" }
{ "mode": "custom" }Credits
Everything a production does is paid for in credits from your balance. What a credit is worth is in your account (credits.valueCents).
- Price it first, if you like, with the estimate: what starting takes, and the likely range for the whole film.
- At the start, only the credits for writing the script are reserved. If your balance can't cover them, creating answers
402 insufficient_creditsand nothing is created. - As it runs, each later step holds its own credits as it begins, priced on facts — the real shot list, the measured narration. If your balance can't fund the next step, the production pauses instead of overdrawing:
statusinsufficient_credits, and theproduction.insufficient_creditswebhook. Top up and continue it; nothing already made is paid for twice. - At the end, the charge is settled to what the work actually cost, and anything held but unused is refunded. A failed production is refunded in full; a canceled one pays for the work already done.
On the production, reservedCredits is what is held right now, spentCredits what has been spent so far, and chargedCredits the final charge once it has finished.
Webhooks
Webhooks tell your server when something happens to a production, so you don't have to keep asking. Add endpoints on the API keys page. Each gets its own signing secret, starting whsec_, shown when you add it. An endpoint's URL must use https; plain http is accepted only for localhost, for testing.
| event | When |
|---|---|
production.completed | The film is finished. Get the files. |
production.failed | It stopped with a problem — failureCode says which. Refunded in full. |
production.awaiting_review | It is waiting at a cut point for you — reviewStage says which. |
production.insufficient_credits | It paused because the next step needs more credits than you have. Top up and continue it. |
production.cancelled | It stopped at your request. |
Every endpoint receives every event. Progress is not an event — poll for that.
What we send
- A
POSTwith a JSON body:id(this delivery's id),event,createdAt, anddata.production— the production as it is at that moment. - The headers
Content-Type: application/json,User-Agent: RollAndCut-Webhooks/1andRoll-And-Cut-Signature. - Answer with any
2xxwithin 10 seconds. Anything else, or no answer, is tried again: six attempts in all, the wait doubling from 15 seconds — about eight minutes from the first attempt to the last. A retry sends the same body with the sameid, so useidto skip a delivery you have already handled. - After the last attempt we stop.
GET /productions/:idalways has the truth — treat webhooks as a way to avoid polling, never as the only way to learn an outcome.
Verifying the signature
Roll-And-Cut-Signature: t=<unix seconds>,v1=<hex> — v1 is an HMAC-SHA256, keyed with the endpoint's signing secret (the whole string, whsec_ included), of the timestamp, a dot, and the raw body: `${t}.${rawBody}`.
Verify every delivery. Compute the HMAC over the body exactly as it arrived — before any JSON parsing, which would change the bytes — compare in constant time, and refuse a timestamp more than five minutes from now, so a captured delivery can't be replayed.
POST /hooks/rollandcut HTTP/1.1
Content-Type: application/json
User-Agent: RollAndCut-Webhooks/1
Roll-And-Cut-Signature: t=1790695183,v1=38e36536e904ce3dd601b0ad62d7c7b981017c9086d5ceba290bbcfe3baf6b50{
"id": "0c6f5a8e-2b47-4d1f-9a2e-7e5b1c2a4f60",
"event": "production.completed",
"createdAt": "2026-09-29T15:19:43.118Z",
"data": {
"production": {
"id": "5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35",
"status": "completed",
"prompt": "A 45-second documentary about how octopuses hunt on coral reefs at night",
"length": "30_60s",
"aspectRatio": "9:16",
"look": "motion",
"resolution": "720p",
"filmLanguage": "en",
"progressPercent": 100,
"currentStage": null,
"reviewStage": null,
"title": "Hunters of the Night Reef",
"shotCount": 12,
"failureCode": null,
"reservedCredits": 0,
"spentCredits": 867,
"chargedCredits": 867,
"filesExpireAt": "2026-10-13T15:19:42.000Z",
"filesDeletedAt": null,
"createdAt": "2026-09-29T15:04:05.000Z",
"updatedAt": "2026-09-29T15:19:42.000Z"
}
}
}import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
// rawBody: the request body exactly as it arrived, before any JSON parsing.
// secret: the endpoint's signing secret, whsec_ prefix included.
export function verifySignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((part) => {
const at = part.indexOf("=");
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
})
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return (
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
);
}
// With Express: keep the body raw on this route.
const app = express();
app.post("/hooks/rollandcut", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const header = req.get("Roll-And-Cut-Signature") ?? "";
if (!verifySignature(rawBody, header, process.env.ROLL_AND_CUT_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const delivery = JSON.parse(rawBody);
// Deliveries can repeat: skip an id you have already handled.
if (delivery.event === "production.completed") {
// delivery.data.production.id is ready: fetch its files.
}
res.sendStatus(200);
});How long files are kept
A production's files are kept for 14 days after it stops — completes, fails, is canceled, or pauses at a cut point or for credits — and are then deleted: a pause nobody picks up expires on that date, and its files go with it the same day. filesExpireAt on the production is the date. filesDeletedAt is set once they are gone; its files then answer 410 expired, and it can no longer be continued. The production itself stays: GET /productions/:id still answers with its prompt, settings and cost. Download what you want to keep.