The Reelrail REST API
Everything the web app does with a generation - price it, submit it, follow it, upload the references it needs, narrate it, storyboard it, share it - is one HTTP call with an organization API key. Jobs run against your workspace, under the same plan limits and credit ceilings, and show up in your jobs list either way.
https://api.reelrail.appWant an assistant to drive Reelrail instead of your code? Use the MCP server.
Authenticate
Nothing under /v1 is anonymous. An unauthenticated request answers 401 rather than 404, so nobody can map the surface by probing it. Send an organization key either way - x-api-key wins when both are present.
curl -sS https://api.reelrail.app/v1/me \
-H "x-api-key: rr_org_xxxxxxxx"
# or
curl -sS https://api.reelrail.app/v1/me \
-H "Authorization: Bearer rr_org_xxxxxxxx"Create and revoke keys under API keys. A key belongs to the workspace rather than to you, is shown once, and spends real credits. It may never expire, or expire between 1 and 365 days from now.
| Preset | Permissions | Unlocks |
|---|---|---|
| Generate + read | jobs: read, create, cancel | assets: read, create | models: read | Everything on this page. |
| Read only | jobs: read | assets: read | models: read | The GET routes. Anything that spends credits answers 403 forbidden. |
A permission miss is 403 forbidden. An id from another workspace is 404 not_found - a key can never learn whether someone else's job exists.
The response envelope
Success carries ok: true, failure carries ok: false and an error object - the same envelope the web app's own BFF uses. Branch on ok or on the HTTP status; they always agree.
HTTP/1.1 200 OK
{ "ok": true, "data": { "orgId": "01J...", "balance": 800 } }HTTP/1.1 400 Bad Request
{ "ok": false,
"error": { "code": "validation_error", "message": "invalid presign input",
"details": { "issues": ["mime"] } } }error.code always matches the status: unauthorized 401, forbidden 403, not_found 404, plan_limit and insufficient_credits 402, rate_limited and concurrency_limit 429, references_too_large 413. The full table, with what each one means, is in docs/api.md in the repository.
Limits
| Limit | Value | Applies to |
|---|---|---|
| RL_SUBMIT | 30 / 60 s per org | POST /v1/jobs, and each sequence shot it submits |
| RL_ESTIMATE | 120 / 60 s per org | POST /v1/estimate, the copilot, and both speech routes |
| Per-key budget | 120 / 60 s per key | Every route; answered 429 rate_limited |
| JSON body | 256 KB | Every route that reads a body; refused before it is parsed |
| Timeline body | 1 MB | PATCH /v1/projects/:id/timeline only; refused before it is parsed |
| References | 50 per job, 2 frame images | CreateJobInput; the model's own limits apply on top |
| Prompt | 4,000 characters | CreateJobInput.prompt and the copilot |
| Speech script | 2,000 characters | POST /v1/speech/synthesize |
| Captions source | 20 MB | POST /v1/speech/captions |
| Shots per sequence | 24 | CreateSequenceInput.shots |
| Timeline document | 12 tracks, 200 clips per track, 600 clips, 3,600 s | The EDL on PATCH /v1/projects/:id/timeline (EDL_LIMITS) |
| Uploads | image 25 MB, audio 50 MB, video 200 MB - 1 GB by plan | POST /v1/uploads/presign |
| Signed media URLs | view 6 h, download 15 min | media.view / media.poster / media.download - credentials, never log them |
Routes
Ids are ULIDs, timestamps are epoch milliseconds, and money is credits (100 credits = $1). Pagination is keyset: pass the previous nextCursor back as cursor until it is null.
Identity
Check that a key works and see what it can do. No credential material is ever returned.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| GET /v1/me | any valid key | The resolved principal: organization, acting user, key id, permissions. | { kind, orgId, userId, keyId, permissions } |
| POST /v1/me | any valid key | Same answer as GET. POST exists for internally signed callers. | { kind, orgId, userId, keyId, permissions } |
Catalog
The video models you may generate with, normalized and with Reelrail's overrides applied.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| GET /v1/models | models: read (or jobs: read) | The normalized video catalog. Cache on the returned etag. | CatalogSnapshot { models[], etag } - resolutions, durations, sizes, passthrough keys |
Credits
100 credits = $1. Every generation places a hold first and settles afterwards.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| GET /v1/credits/balance | billing: read (or jobs: read) | The workspace's credit balance. | { orgId, balance } |
Jobs
Price a request, submit it, follow it, cancel it. orgId and userId come from the key - a body that claims otherwise is ignored.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| POST /v1/estimate | jobs: create | Price a request without creating anything. Never holds credits and never throws for an empty balance.EstimateJobInput (CreateJobInput without idempotencyKey)Free | EstimateJobResult { estimate, balance, canSubmit, blockers[], blockerMessages[], warnings[] } |
| POST /v1/jobs | jobs: create | Submit a generation. 201 for a fresh job, 200 when idempotencyKey matched an existing one.CreateJobInput - modelId, prompt, params, references, frameImages, pipeline, idempotencyKeyHolds the estimate, settles on completion | CreateJobResult { jobId, status, estimate, deduplicated, warnings[] } |
| GET /v1/jobs | jobs: read | Your jobs, newest first. Keyset pagination.Query: status, modelId, projectId, cursor, limit (<=100), updatedSince | ListJobsResult { jobs[], nextCursor } |
| GET /v1/jobs/:id | jobs: read | One job with its recent events and freshly signed media URLs. | JobDto - status, params, credits, pipeline, parentJobId, events[], media{view,poster,download} |
| POST /v1/jobs/:id/cancel | jobs: cancel | Terminate the workflow and release the hold. A late provider completion is stored but not charged.Releases the hold | JobDto with status cancelled |
Uploads
Ask for a target, PUT the bytes, then complete. Only a completed asset is usable as a reference.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| POST /v1/uploads/presign | assets: create | A one-object upload target, valid for one hour.PresignInput { kind, mime, sizeBytes, filename?, projectId? } | PresignResult { mode, assetId, key, url, method, headers, expiresAt, maxBytes } |
| POST /v1/uploads/complete | assets: create | Verify the bytes that landed (size + magic number) and mark the asset ready. Reference uploads only: an asset the engine reserved for you - a Studio export's output, a job output, a poster - is published by the call that reserved it and is refused here.CompleteUploadInput { assetId, width?, height?, durationSeconds?, rightsAttested? } | AssetDto { id, kind, mime, status, sizeBytes, width, height, thumbAssetId, ... } |
| POST /v1/uploads/poster | assets: create | Turn an uploaded image into a finished job's poster when the server-side one is missing.{ jobId, assetId } | { jobId, posterAssetId } |
| POST /v1/uploads/:assetId/attestNew | assets: create | Record the likeness-rights attestation on an image that was already uploaded. Write-once; a repeat returns the first timestamp. | AttestAssetRightsResult { assetId, rightsAttestedAt } |
Prompt copilot
Rewrites a draft into a prompt the selected model can use. Never creates a job.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| POST /v1/copilot/improve | jobs: create | Improve a prompt for a specific model, keeping reference tags intact.ImprovePromptInput { modelId, prompt, negativePrompt?, references[], cameraPresetId?, requestId? }1 credit, free on Pro and Studio | ImprovePromptResult { prompt, negativePrompt?, camera?, notes[], provider, model, creditsCharged } |
Speech and captions
Workers AI text-to-speech and WebVTT captions. Charged per call, not as jobs.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| POST /v1/speech/synthesizeNew | jobs: create + assets: create | Turn a script into a reusable audio asset (aura-1, MP3, twelve voices).SynthesizeSpeechInput { text (<=2000), voice?, projectId?, requestId? }Per character; 7 credits for a full 2,000-character script at the Free markup | SynthesizeSpeechResult { assetId, mime, sizeBytes, durationSeconds, voice, chars, creditsCharged, viewUrl } |
| POST /v1/speech/captionsNew | jobs: create + assets: create | WebVTT captions for an audio or video asset you own (Whisper, <=20 MB source).TranscribeCaptionsInput { assetId, language?, requestId? }1 credit for any realistic clip | TranscribeCaptionsResult { captionAssetId, vtt, text, durationSeconds, creditsCharged } |
Sequences
A shared reference set, default parameters and up to 24 shots. Running one submits a job per shot.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| POST /v1/sequencesNew | jobs: create | Create a sequence. Answers 201.CreateSequenceInput { name, modelId, references[], defaultParams, seedPolicy, seed?, shots[1..24] } | SequenceDto { id, name, modelId, defaultParams, seedPolicy, status, shots[] } |
| GET /v1/sequencesNew | jobs: read | Your sequences, newest first.Query: projectId, cursor, limit (<=100), includeArchived | ListSequencesResult { sequences[] (shotCount, completedCount, posterUrl), nextCursor } |
| GET /v1/sequences/:idNew | jobs: read | One sequence with every shot and the job behind it. | SequenceDto with shots[].job (status, media, credits, error) |
| PATCH /v1/sequences/:idNew | jobs: create | Update name, references, defaults, seed policy or shots. A shot sent with its id keeps its job history.UpdateSequenceInput minus sequenceId (the path wins) | The updated SequenceDto |
| DELETE /v1/sequences/:idNew | jobs: create | Archive, not delete: the shots point at real jobs whose ledger rows are append-only. | SequenceDto with status archived |
| POST /v1/sequences/:id/runNew | jobs: create | Submit the shots that have no job yet, as many as the concurrency cap allows. Call again when slots free up - it is idempotent per shot.RunSequenceInput { shotIds? }One hold per submitted shot | RunSequenceResult { submitted[{shotId,jobId}], skipped[{shotId,code,message}], remaining } |
| POST /v1/sequences/:id/shots/:shotId/regenerateNew | jobs: create | Re-render one shot. Send one requestId per Regenerate click so a double-click costs one render.RegenerateShotInput { prompt?, cameraPresetId?, params?, requestId? }One hold per render | The updated SequenceDto |
Share links
Publish a finished job or a sequence at /s/<slug> on the web app. Public resolution is never an API-key surface.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| POST /v1/share-linksNew | jobs: create | Create a link. Answers 201. The job must be completed with an output.CreateShareLinkInput { targetType, targetId, expiresInSeconds? (1h..90d), allowRemix? } | ShareLinkDto { slug, targetType, targetId, allowRemix, viewCount, expiresAt, revokedAt } |
| GET /v1/share-linksNew | jobs: read | Your share links.Query: targetType, targetId, limit (<=100), includeRevoked | { links: ShareLinkDto[] } |
| DELETE /v1/share-links/:slugNew | jobs: create | Revoke a link. The public page 404s immediately; revoking twice is not an error. | ShareLinkDto with revokedAt set |
Images
Generate stills synchronously: one call validates, holds, renders, stores and settles. Doc 11 section 5. Until the provider key exists on an environment, generation answers provider_unavailable before any hold.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| GET /v1/images/modelsNew | models: read (or jobs: read) | The normalized image catalog. ?featured=1 returns only the curated shortlist. Cache on the returned etag. | { models[], etag } - parameters as typed descriptors (enum / range / boolean), per-endpoint pricing on the featured models |
| POST /v1/images/estimateNew | jobs: create | Price an image call without running it. Reports blockers instead of throwing.EstimateImageInput { modelId, prompt, params?, references[] }Free | ImageEstimateDto { credits, costUsd, upperUsd, holdCredits, confidence, breakdown, warnings, blockers, blockerMessages, canSubmit, balance, images } |
| POST /v1/imagesNew | jobs: create | Render now. Synchronous: the response carries the finished images. A provider failure answers 200 with status 'failed' and charges nothing.GenerateImageInput { modelId, prompt, params?, references[], projectId?, idempotencyKey? }Provider cost x the plan markup, held up front and settled from usage.cost; a failed call costs nothing | GenerateImageResult { jobId, status, deduplicated, images[], estimate, actual, warnings, error } |
Projects and timelines
The Studio editor. A project is a name plus a stack of timeline versions; a version holds one EDL - the edit decision list the preview engine, the client exporter and the cloud renderer all read. Creating a project needs the Starter plan or above; reading, saving and archiving an existing one do not. Doc 12 sections 3-4.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| GET /v1/projectsNew | jobs: read | Your projects, newest first.Query: cursor, limit (<=100), includeArchived | { items: ProjectDto[] (timelineVersion, updatedAt), nextCursor } |
| POST /v1/projectsNew | jobs: create | Create a project. Timeline version 1 (an empty document) is written in the same transaction. Answers 201. Starter plan and above; Free answers 402 plan_limit with details.unlockingPlan.{ name (1-120), settings? { width, height, fps, background } } | ProjectDto { id, name, createdBy, createdAt, archivedAt, timelineVersion, updatedAt } |
| GET /v1/projects/:idNew | jobs: read | One project. Another workspace's id is not_found, never forbidden. | ProjectDto |
| PATCH /v1/projects/:idNew | jobs: create | Rename. Omitted fields are left alone; an archived project is validation_error.{ name? (1-120) } | The updated ProjectDto |
| DELETE /v1/projects/:idNew | jobs: create | Archive, not delete: the timelines and renders point at real assets. Archiving twice is not an error. | ProjectDto with archivedAt set |
| GET /v1/projects/:id/timelineNew | jobs: read | The latest timeline, or ?version=N. A document stored under an older schema is migrated on read. A pruned version is not_found.Query: version (positive integer) | TimelineDto { projectId, version, edl, createdBy, createdAt } |
| PATCH /v1/projects/:id/timelineNew | jobs: create | One autosave flush; writes version = baseVersion + 1. A stale base answers 409 conflict with details { currentVersion, edl } - the server document - so the client can reconcile and retry. The raw body is bounded at 1 MB, and every assetId / jobId the document names must belong to your workspace.{ baseVersion (>= 0), edl } - at most 1 MB, refused before it is parsed | The new TimelineDto, or 409 { code: 'conflict', details: { currentVersion, edl } } |
| GET /v1/projects/:id/timeline/versionsNew | jobs: read | Version history, newest first, summaries only. The newest 50, every 25th and version 1 are kept; the flushes between them are pruned.Query: limit (<=100) | { items: [{ version, createdBy, createdAt, durationSeconds }] } |
Renders (client export and cloud render)
One render records one export of one timeline version, in one of two modes. In the client path the browser encodes with WebCodecs and the engine only ever sees bytes: create opens the row, presign reserves an output asset and hands back a PUT target, complete verifies the object and registers it, fail releases it, and it costs no credits. In the cloud path (Pro and above) the engine renders the timeline itself on a Cloudflare Container: one call, then render.progress / render.completed / render.failed events on /ws. A cloud render is charged in full up front - 5 credits per output minute at 720p, 10 at 1080p, one-minute minimum - and every failure or cancel refunds it. A client render left in rendering or storing for two hours is failed by the maintenance sweep; a cloud render is failed and refunded after 45 minutes. Doc 12 sections 3 and 6.
| Route | Permission | What it does | Answers |
|---|---|---|---|
| POST /v1/projects/:id/rendersNew | jobs: create | Open a render for a stored timeline version. Answers 201 - status 'rendering' for the client path, 'queued' for the cloud path. mode 'cloud' needs the Pro plan (402 plan_limit naming pro), is MP4 only, is 720p or 1080p only (preset '4k' answers 400 with details.unavailable), caps the timeline at 720 seconds of output, needs every asset ready, and charges credits before it queues (402 insufficient_credits with details.required, nothing written). Where no renderer is deployed it is 503 not_configured, refused before any read. On the client path preset '4k' is cloud-only and answers 400 with details.cloudOnly.{ timelineVersion, mode: 'client' | 'cloud', preset: '720p' | '1080p' | '4k', container?: 'mp4' | 'webm' }None for mode 'client'. For mode 'cloud': 5 credits per output minute at 720p, 10 at 1080p, one-minute minimum, exact rather than estimated, refunded in full on any failure or cancel. | RenderDto { id, projectId, timelineVersion, mode, status, preset, container, outputAssetId, output, credits, progress, error, createdAt, updatedAt, completedAt } |
| GET /v1/projects/:id/rendersNew | jobs: read | A project's renders, newest first.Query: limit (<=100) | { items: RenderDto[] } |
| GET /v1/renders/:idNew | jobs: read | One render. A completed one carries output with signed view and download URLs, minted per read; a running cloud render carries progress (0..1). Another workspace's id is not_found, never forbidden. | RenderDto with output { assetId, mime, sizeBytes, width, height, durationSeconds, url, downloadUrl } |
| POST /v1/renders/:id/presignNew | jobs: create | Reserve the output asset and get the PUT target; the render moves to 'storing'. Same transport as POST /v1/uploads/presign - presigned when the R2 S3 token exists, a direct <=90 MB PUT to the web app otherwise. Retrying with the same mime answers the same target and updates the declared size; a different mime releases the old reservation and reserves a fresh one.{ mime: 'video/mp4' | 'video/webm', sizeBytes (the exact encoded length) } | { render: RenderDto, upload: { mode, assetId, key, url, method, headers, expiresAt, maxBytes } } |
| POST /v1/renders/:id/completeNew | jobs: create | Verify the uploaded object and publish it: size against what was declared and the plan cap, then magic bytes against the declared mime. A refusal deletes the object and fails both the asset and the render. Idempotent once completed.{ width, height, durationSeconds } - what the browser measured | RenderDto with status 'completed' and output populated |
| POST /v1/renders/:id/failNew | jobs: create | Record that the browser gave up, or cancel a cloud render that is running now; any reserved output asset is released. On a cloud render this also stops the container and refunds the charge in full - credits on the DTO still reads what the render cost, and the render.failed event carries creditsRefunded. Refused after completion, idempotent from a terminal state.{ status: 'failed' | 'cancelled', error? (<=500 chars) } | RenderDto with that status, completedAt set, progress cleared and error stored verbatim |
A first call
curl -sS https://api.reelrail.app/v1/estimate \
-H "x-api-key: rr_org_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"source":"api","type":"video","modelId":"<from /v1/models>",
"prompt":"A product turntable on a white cyclorama",
"params":{"durationSeconds":6,"resolution":"720p","aspectRatio":"1:1"}}'
curl -sS https://api.reelrail.app/v1/jobs \
-H "x-api-key: rr_org_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"source":"api","type":"video","modelId":"<from /v1/models>",
"prompt":"A product turntable on a white cyclorama",
"params":{"durationSeconds":6,"resolution":"720p","aspectRatio":"1:1"},
"idempotencyKey":"turntable-001"}'Poll GET /v1/jobs/:id until the status is completed, then read media.view. Those URLs are signed and expire - six hours for playback, fifteen minutes for a download - so mint a fresh one by re-reading the job rather than storing the link.
Not for API keys
These exist on the engine and refuse an organization key.
| Surface | Who may call it |
|---|---|
| /v1/internal/* | The web Worker only, over a signed HMAC envelope. |
| /v1/admin/* | Platform operators named in ADMIN_USER_IDS. A key is not a person. |
| /ws | Browsers, with a 300 s client token minted by the web app. |
| /m/* | Signed media URLs and media-scoped client tokens. |
| /mcp | MCP clients over OAuth - or an organization key, as an alternative. See /docs/mcp. |
| /webhooks/* | The upstream provider, verified by signature. |
Image routes are not documented yet; they land with image generation.