Reelrail

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.

Base URL
https://api.reelrail.app

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

Check a key works
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.

PresetPermissionsUnlocks
Generate + readjobs: read, create, cancel | assets: read, create | models: readEverything on this page.
Read onlyjobs: read | assets: read | models: readThe 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.

Success
HTTP/1.1 200 OK
{ "ok": true, "data": { "orgId": "01J...", "balance": 800 } }
Failure
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

LimitValueApplies to
RL_SUBMIT30 / 60 s per orgPOST /v1/jobs, and each sequence shot it submits
RL_ESTIMATE120 / 60 s per orgPOST /v1/estimate, the copilot, and both speech routes
Per-key budget120 / 60 s per keyEvery route; answered 429 rate_limited
JSON body256 KBEvery route that reads a body; refused before it is parsed
Timeline body1 MBPATCH /v1/projects/:id/timeline only; refused before it is parsed
References50 per job, 2 frame imagesCreateJobInput; the model's own limits apply on top
Prompt4,000 charactersCreateJobInput.prompt and the copilot
Speech script2,000 charactersPOST /v1/speech/synthesize
Captions source20 MBPOST /v1/speech/captions
Shots per sequence24CreateSequenceInput.shots
Timeline document12 tracks, 200 clips per track, 600 clips, 3,600 sThe EDL on PATCH /v1/projects/:id/timeline (EDL_LIMITS)
Uploadsimage 25 MB, audio 50 MB, video 200 MB - 1 GB by planPOST /v1/uploads/presign
Signed media URLsview 6 h, download 15 minmedia.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.

RoutePermissionWhat it doesAnswers
GET /v1/meany valid keyThe resolved principal: organization, acting user, key id, permissions.{ kind, orgId, userId, keyId, permissions }
POST /v1/meany valid keySame 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.

RoutePermissionWhat it doesAnswers
GET /v1/modelsmodels: 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.

RoutePermissionWhat it doesAnswers
GET /v1/credits/balancebilling: 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.

RoutePermissionWhat it doesAnswers
POST /v1/estimatejobs: createPrice a request without creating anything. Never holds credits and never throws for an empty balance.EstimateJobInput (CreateJobInput without idempotencyKey)FreeEstimateJobResult { estimate, balance, canSubmit, blockers[], blockerMessages[], warnings[] }
POST /v1/jobsjobs: createSubmit 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 completionCreateJobResult { jobId, status, estimate, deduplicated, warnings[] }
GET /v1/jobsjobs: readYour jobs, newest first. Keyset pagination.Query: status, modelId, projectId, cursor, limit (<=100), updatedSinceListJobsResult { jobs[], nextCursor }
GET /v1/jobs/:idjobs: readOne 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/canceljobs: cancelTerminate the workflow and release the hold. A late provider completion is stored but not charged.Releases the holdJobDto with status cancelled

Uploads

Ask for a target, PUT the bytes, then complete. Only a completed asset is usable as a reference.

RoutePermissionWhat it doesAnswers
POST /v1/uploads/presignassets: createA 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/completeassets: createVerify 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/posterassets: createTurn an uploaded image into a finished job's poster when the server-side one is missing.{ jobId, assetId }{ jobId, posterAssetId }
POST /v1/uploads/:assetId/attestNewassets: createRecord 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.

RoutePermissionWhat it doesAnswers
POST /v1/copilot/improvejobs: createImprove a prompt for a specific model, keeping reference tags intact.ImprovePromptInput { modelId, prompt, negativePrompt?, references[], cameraPresetId?, requestId? }1 credit, free on Pro and StudioImprovePromptResult { prompt, negativePrompt?, camera?, notes[], provider, model, creditsCharged }

Speech and captions

Workers AI text-to-speech and WebVTT captions. Charged per call, not as jobs.

RoutePermissionWhat it doesAnswers
POST /v1/speech/synthesizeNewjobs: create + assets: createTurn 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 markupSynthesizeSpeechResult { assetId, mime, sizeBytes, durationSeconds, voice, chars, creditsCharged, viewUrl }
POST /v1/speech/captionsNewjobs: create + assets: createWebVTT captions for an audio or video asset you own (Whisper, <=20 MB source).TranscribeCaptionsInput { assetId, language?, requestId? }1 credit for any realistic clipTranscribeCaptionsResult { captionAssetId, vtt, text, durationSeconds, creditsCharged }

Sequences

A shared reference set, default parameters and up to 24 shots. Running one submits a job per shot.

RoutePermissionWhat it doesAnswers
POST /v1/sequencesNewjobs: createCreate a sequence. Answers 201.CreateSequenceInput { name, modelId, references[], defaultParams, seedPolicy, seed?, shots[1..24] }SequenceDto { id, name, modelId, defaultParams, seedPolicy, status, shots[] }
GET /v1/sequencesNewjobs: readYour sequences, newest first.Query: projectId, cursor, limit (<=100), includeArchivedListSequencesResult { sequences[] (shotCount, completedCount, posterUrl), nextCursor }
GET /v1/sequences/:idNewjobs: readOne sequence with every shot and the job behind it.SequenceDto with shots[].job (status, media, credits, error)
PATCH /v1/sequences/:idNewjobs: createUpdate 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/:idNewjobs: createArchive, not delete: the shots point at real jobs whose ledger rows are append-only.SequenceDto with status archived
POST /v1/sequences/:id/runNewjobs: createSubmit 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 shotRunSequenceResult { submitted[{shotId,jobId}], skipped[{shotId,code,message}], remaining }
POST /v1/sequences/:id/shots/:shotId/regenerateNewjobs: createRe-render one shot. Send one requestId per Regenerate click so a double-click costs one render.RegenerateShotInput { prompt?, cameraPresetId?, params?, requestId? }One hold per renderThe 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.

RoutePermissionWhat it doesAnswers
POST /v1/share-linksNewjobs: createCreate 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-linksNewjobs: readYour share links.Query: targetType, targetId, limit (<=100), includeRevoked{ links: ShareLinkDto[] }
DELETE /v1/share-links/:slugNewjobs: createRevoke 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.

RoutePermissionWhat it doesAnswers
GET /v1/images/modelsNewmodels: 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/estimateNewjobs: createPrice an image call without running it. Reports blockers instead of throwing.EstimateImageInput { modelId, prompt, params?, references[] }FreeImageEstimateDto { credits, costUsd, upperUsd, holdCredits, confidence, breakdown, warnings, blockers, blockerMessages, canSubmit, balance, images }
POST /v1/imagesNewjobs: createRender 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 nothingGenerateImageResult { 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.

RoutePermissionWhat it doesAnswers
GET /v1/projectsNewjobs: readYour projects, newest first.Query: cursor, limit (<=100), includeArchived{ items: ProjectDto[] (timelineVersion, updatedAt), nextCursor }
POST /v1/projectsNewjobs: createCreate 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/:idNewjobs: readOne project. Another workspace's id is not_found, never forbidden.ProjectDto
PATCH /v1/projects/:idNewjobs: createRename. Omitted fields are left alone; an archived project is validation_error.{ name? (1-120) }The updated ProjectDto
DELETE /v1/projects/:idNewjobs: createArchive, not delete: the timelines and renders point at real assets. Archiving twice is not an error.ProjectDto with archivedAt set
GET /v1/projects/:id/timelineNewjobs: readThe 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/timelineNewjobs: createOne 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 parsedThe new TimelineDto, or 409 { code: 'conflict', details: { currentVersion, edl } }
GET /v1/projects/:id/timeline/versionsNewjobs: readVersion 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.

RoutePermissionWhat it doesAnswers
POST /v1/projects/:id/rendersNewjobs: createOpen 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/rendersNewjobs: readA project's renders, newest first.Query: limit (<=100){ items: RenderDto[] }
GET /v1/renders/:idNewjobs: readOne 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/presignNewjobs: createReserve 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/completeNewjobs: createVerify 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 measuredRenderDto with status 'completed' and output populated
POST /v1/renders/:id/failNewjobs: createRecord 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

Price a shot, then submit it
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.

SurfaceWho 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.
/wsBrowsers, with a 300 s client token minted by the web app.
/m/*Signed media URLs and media-scoped client tokens.
/mcpMCP 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.