Roll & Cut
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.

Quick start

Three steps from nothing to a finished film.

  1. 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. 2

    Start a production

    POST /productions with 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. 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. Once status is completed, GET /productions/:id/assets has the download links.

The round trip
# 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

  • 401unauthorizedNo Authorization header, or a key that is malformed, unknown or revoked.
Request
curl https://rollandcutlabs.com/api/v1/account \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 401
{
  "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.
Response headers
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: v1

Rate 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.

HeaderWhat it says
X-RateLimit-LimitYour requests per minute.
X-RateLimit-RemainingHow many are left this minute.
X-RateLimit-ResetSeconds 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.

Response · 429
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
Body · 429
{
  "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.
A replayed answer
HTTP/1.1 201 Created
Content-Type: application/json
Idempotent-Replay: true
Response · 409
{
  "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.

CodeStatusMeaning
invalid_request400The 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.
unauthorized401No API key, or a key that is malformed, unknown or revoked.
forbidden403Your account has been closed.
email_unverified403Your email address isn't confirmed yet. Starting a production needs a confirmed address: open the link we emailed you, then try again.
not_found404No production with that id on your account. Someone else's production answers the same way.
conflict409The 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_credits402Your balance can't cover what this needs to start, or to go on.
rate_limited429Over this key's requests per minute. Wait Retry-After seconds.
expired410The production's files have been deleted.
film_language_unsupported422The 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_error500Something 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.

Response · 400
{
  "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 characters
Makes the call safe to retry — see Idempotency.

Body

promptrequiredstring
What the film is about, up to 20,000 characters. For a story, a detailed prompt — who is in it, where it happens, what happens, the tone — makes a far better film than a single line; for an explainer or a documentary, a sentence is enough.
recipeobject
How to make it. Every field is optional — see The recipe.
durationTierIdstring
An older way to set the length. Used only when the recipe sets no length.
filmLanguagestring
The language the film is written and spoken in: 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 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

  • 400invalid_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 (details lists every reason), filmLanguage isn't a language code, or our content check refused the prompt. Nothing is created or charged.
  • 422film_language_unsupportedThe prompt asks for a film in a language we don't make films in yet, or filmLanguage names one — language says which. Nothing is created or charged; send it again with filmLanguage set to a language we do.
  • 402insufficient_creditsYour balance can't cover the credits this production reserves to start. Nothing is created.
  • 403email_unverifiedYour email address isn't confirmed yet. Nothing is created.
  • 403forbiddenYour account has been closed.
  • 409conflictThe Idempotency-Key was used with a different body, or its first request is still being processed.
Request
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"
      }
    }
  }'
Response · 201
{
  "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, and wallet.sufficient says whether it does.
  • bandLowCredits to bandHighCredits — 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.coversEstimate says 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

recipeobject
As for creating — see The recipe.
promptstring
The prompt you will send, so that a long one is priced in. Up to 20,000 characters.
durationTierIdstring
An older way to set the length. Used only when the recipe sets no length.

Returns

200 OK with estimate and wallet (balanceCredits, sufficient, coversEstimate).

Errors

  • 400invalid_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 — details lists every reason. No price is quoted for a production that would be refused.
Request
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"
      }
    }
  }'
Response · 200
{
  "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

limitinteger
How many to return, from 1 to 100. Default 20.
offsetinteger
How many to skip. Default 0.

Returns

200 OK with productions, the limit and offset used, and hasMore — true when there is another page: ask again with offset plus limit.

Request
curl "https://rollandcutlabs.com/api/v1/productions?limit=20&offset=0" \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 200
{
  "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

idrequiredstring
The production's id.

Returns

200 OK with { "production": … } — the production.

Errors

  • 404not_foundNo production with that id on your account.
Request
curl https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35 \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 200
{
  "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.

kindWhat it is
finalThe finished piece, audio mixed in. An MP4.
final_cleanThe same film with no burned-in text — pair it with a caption file. Only when subtitles were on.
stem_videoThe picture with no audio, for scoring it yourself.
stem_narrationEvery spoken line at its real offset, full length.
stem_musicThe music bed on its own.
stem_worldThe sound of each place on its own — the room and the world under every scene.
caption_fileA caption file, when subtitles were on — one SRT and one WebVTT for every language delivered. refKey names it: en:srt, es-419:vtt.
keyframeA storyboard picture — one per shot, numbered by beatIndex from 0.
character_ref, location_ref, prop_refReference 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

idrequiredstring
The production's id.

Returns

200 OK with assets — the files with no beatIndex first, then the storyboard pictures in shot order. Each has:

kindstring
Which file — see the table above.
beatIndexinteger or null
For a storyboard picture, the shot it belongs to. Otherwise null.
refKeystring or null
For a reference picture, which one it is; for a caption file, its language and format, such as en:srt. Otherwise null.
contentTypestring
The file's media type, such as video/mp4 or image/png.
sizeBytesinteger or null
The file's size.
expiresAtstring or null
When the production's files are deleted — its filesExpireAt. Null while it runs.
urlstring
A signed download link. It works for at least five minutes after you receive it, and at most an hour — download promptly, and call again for fresh links.

Errors

  • 404not_foundNo production with that id on your account.
  • 410expiredIts files have been deleted — see How long files are kept. The message gives the date.
Request
curl https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35/assets \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 200
{
  "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

idrequiredstring
The production's id.

Returns

202 Accepted with production (as it was when the request was recorded), outcome and note.

Errors

  • 404not_foundNo production with that id on your account.
  • 409conflictIt has already stopped: it is completed, failed, canceled or expired.
Request
curl -X POST https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35/cancel \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 202
{
  "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.

A production waiting at a cut point (awaiting_review) is answered on its page in the app. Continuing one is refused with conflict.

Path parameters

idrequiredstring
The production's id.

Returns

202 Accepted with production — its status now queued — and note.

Errors

  • 404not_foundNo production with that id on your account.
  • 409conflictIt isn't waiting to be continued, it is waiting at a cut point, it has no script yet, or its files have been deleted.
  • 402insufficient_creditsYour balance, plus what it still holds, doesn't cover the next step — the message says how many more you need.
  • 403forbiddenYour account has been closed.
Request
curl -X POST https://rollandcutlabs.com/api/v1/productions/5b0e8c2a-7f1e-4a91-b6e2-1c9d0f4a7e35/continue \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 202
{
  "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.

Request
curl https://rollandcutlabs.com/api/v1/styles \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 200 · 1 of 18 styles
{
  "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 its id, the role it fills (writersRoom, camera or motion), a name and a description. Put the id in the recipe's crew, under its role. A camera or motion model also has resolutions: 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 its id as crew.profileId.
  • voices — the voices a film can be cast from, each with its id, name, description, gender, age, accent and the film languages it speaks. Put ids in the recipe's voices to cast only from those.

Returns

200 OK with models, profiles, voices and note.

Request
curl https://rollandcutlabs.com/api/v1/models \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 200 · an excerpt of 20 models, 3 crews and 132 voices
{
  "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: available is what a new production can draw on now; held is reserved by the productions still running — heldRuns of them — and whatever a run doesn't use comes back when it settles; valueCents is what one credit is worth, in US cents; and availableUsd is available as a display string.
  • limits: requestsPerMinute for each key, how many webhookEndpoints an account can add, every length a 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.

Request
curl https://rollandcutlabs.com/api/v1/account \
  -H "Authorization: Bearer $ROLL_AND_CUT_API_KEY"
Response · 200
{
  "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.

idstring
The production's id.
statusstring
Where it stands — see Statuses.
promptstring
The prompt you sent.
lengthstring
The recipe's length.
aspectRatiostring
16:9, 9:16 or 1:1.
lookstring
How the film is made: stills (Moving stills), mixed (Mixed) or motion (Full motion) — see the recipe's look.
motionAmountstring
A Mixed film only: how much of it moves — touch, some or lots — see the recipe's motionAmount.
resolutionstring
The film's resolution: 720p, 1080p, 1440p or 4k.
filmLanguagestring
The language the film is written and spoken in — see Create a production.
progressPercentinteger
From 0 to 100.
currentStagestring or null
The step running now — such as script, 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 null
While status is awaiting_review, which cut point is waiting. Otherwise null.
titlestring or null
The film's title, once the script is written.
shotCountinteger or null
How many shots, once the script is written.
failureCodestring or null
Why it failed — see Failure codes. Null unless it failed.
reservedCreditsinteger or null
Credits held right now for steps not yet settled. It shrinks as each step settles.
spentCreditsinteger or null
Credits spent so far — the number to watch while it runs.
chargedCreditsinteger or null
The final charge, once the production has finished. Null until then.
filesExpireAtstring or null
When its files will be deleted — see How long files are kept. Null while it runs.
filesDeletedAtstring or null
When they were deleted. Null while they exist.
createdAtstring
When it was created.
updatedAtstring
When it last changed.

Statuses

statusMeaning
queuedAccepted, and waiting to start — or to resume.
in_progressRunning.
awaiting_reviewPaused at a cut point until you answer it in the app — reviewStage says which. Nothing is spent while it waits.
insufficient_creditsPaused because the next step needs more credits than you have. Top up, then continue it.
completedFinished. Get the files.
failedStopped by a problem — failureCode says which. Refunded in full.
cancelledStopped at your request. You pay for the work already done; the rest is refunded.
expiredA 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:

reviewStageCut pointWhat it is for
scriptScriptRead the script before anything is drawn. Turned on with reviewGates.script.
castCastCheck your cast before the storyboard is drawn. Turned on with reviewGates.cast.
shotsStoryboardCheck every shot's picture before any motion is paid for. Turned on with reviewGates.shots.
clipsShots that couldn't be animatedOpens on its own when some shots couldn't be animated, so you can decide what happens to them.
picturesPictures that couldn't be drawnOpens 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

failureCodeMeaning
generation_failedOne of the creative steps didn't return a usable result. Running it again usually works.
content_rejectedPart of it didn't pass content safety checks. Reword the prompt and run it again.
provider_unavailableOne of our creative services was temporarily unavailable. Try again shortly.
insufficient_creditsYour credits ran out partway through. Top up and start it again.
output_too_largeThe finished film came out too large to store. The same request will do the same again — try a shorter length.
internal_errorSomething went wrong on our side. Try again.

A failed production is refunded in full.

A finished production
{
  "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.

lengthstring
How long the film is: under_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".
aspectRatiostring
The frame: 16:9, 9:16 or 1:1. Default "16:9".
lookstring
How the film is made. stills — 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.
motionAmountstring
How much of a mixed 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.
resolutionstring
The film's resolution: 720p, 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
Which genre the film is made in — see Genre. Default {"mode":"auto"}.
audioobject
music 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}.
reviewGatesobject
script, 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}.
subtitlesobject
Burned-in subtitles — see Subtitles. Off unless you send it.
libraryFootageboolean
Let shots of everyday real-world subjects use public-domain footage instead of generated video. Default false.
libraryArchivalPeopleboolean
Also allow historical footage in which people appear. Only matters with libraryFootage 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
Voice ids to cast from; empty lets us choose from the whole catalog. List models has the ids. Default [].
crewobject
Which model does each job: a profileId, or model ids for writersRoom, camera and motion. Empty means our standard crew. List models has the ids. Default {}.
modelsobject
Ours to set — leave it out. A recipe that sets anything in it (scriptModel, 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.

enabledboolean
Turns subtitles on. Default false.
modestring
How they are shown: full_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".
stylestring
shadow or box. Default "shadow".
sizestring
small, medium or large. Default "medium".
accentstring
The highlight color, for modes that use one: amber, sky, green, pink, violet or white. Default "amber".
animationstring
How words arrive, for modes that animate them: fade, pop, rise or none. Default "none".
languagestring
The language burned into the film: film for the film's own language, whichever it is made in, or a code from the list below. Default "film".
captionLanguagesarray of strings
Languages to deliver as caption files — the caption_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
A fuller recipe
{
  "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 /styles has 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

styleIdGenre
nature-science-documentaryNature & science documentary
animated-explainerAnimated explainer
spec-commercialCommercial
quick-factsQuick facts
animated-storyAnimated story
drama-filmDrama film
comedy-sketchComedy sketch
movie-trailerMovie trailer
relaxation-natureRelaxation & nature
history-documentaryHistory documentary
mystery-true-crimeMystery & true crime
horror-storyHorror story
money-business-explainerMoney & business explainer
motivation-philosophyMotivation & philosophy
sci-fi-filmSci-fi film
fantasy-filmFantasy film
anime-inspiredAnime-inspired
countdown-listCountdown list
The three forms
{ "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).

  1. Price it first, if you like, with the estimate: what starting takes, and the likely range for the whole film.
  2. At the start, only the credits for writing the script are reserved. If your balance can't cover them, creating answers 402 insufficient_credits and nothing is created.
  3. 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: status insufficient_credits, and the production.insufficient_credits webhook. Top up and continue it; nothing already made is paid for twice.
  4. 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.

eventWhen
production.completedThe film is finished. Get the files.
production.failedIt stopped with a problem — failureCode says which. Refunded in full.
production.awaiting_reviewIt is waiting at a cut point for you — reviewStage says which.
production.insufficient_creditsIt paused because the next step needs more credits than you have. Top up and continue it.
production.cancelledIt stopped at your request.

Every endpoint receives every event. Progress is not an event — poll for that.

What we send

  • A POST with a JSON body: id (this delivery's id), event, createdAt, and data.production — the production as it is at that moment.
  • The headers Content-Type: application/json, User-Agent: RollAndCut-Webhooks/1 and Roll-And-Cut-Signature.
  • Answer with any 2xx within 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 same id, so use id to skip a delivery you have already handled.
  • After the last attempt we stop. GET /productions/:id always 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.

The request
POST /hooks/rollandcut HTTP/1.1
Content-Type: application/json
User-Agent: RollAndCut-Webhooks/1
Roll-And-Cut-Signature: t=1790695183,v1=38e36536e904ce3dd601b0ad62d7c7b981017c9086d5ceba290bbcfe3baf6b50
The body · production.completed
{
  "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"
    }
  }
}
Verify it
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.