API
Generate video from your own code, with an API key.
The API does what the app does: it makes video. You send a model and a prompt, and you get back a job you can poll until it finishes. Everything it makes lands in this workspace, and every run spends this workspace's credits.
A key can spend money. Treat it like a password: keep it on a server you control, never in a browser or a public repository. Anyone holding it can bill you.
To get one, open Settings, then API, and press New key. Only a workspace admin can create, see or revoke keys. Give it a name so you can tell your keys apart later. The key is shown once, when you create it, and never again, so copy it then.
When you make a key you also choose two limits. A daily spend limit caps how many credits that one key can use in any twenty four hours, and new keys start with one. An expiry date ends the key on its own, and new keys have none unless you pick one.
The API page also has Read the API docs, which opens this article, and the Connect Claude or ChatGPT steps. Full guide, examples and help, under those steps, opens the Connect Claude or ChatGPT article.
Under Using your key, Show the prompt opens the block for an assistant described at the end of this article. On workspaces with their own provider keys, Those are provider keys opens Your own API keys, for the keys you give us.
Send the key as a bearer token on every request. API keys begin with rfk. Plugin tokens begin with rfp and are a different thing. They record how a shot was made and cannot generate, so sending one here gets you a clear message saying so.
Start a five second clip and print the job it created.
curl https://root.film/api/v1/generations \
-H "Authorization: Bearer rfk_your_key" \
-H "Content-Type: application/json" \
-d '{"model":"seedance-2-5","project_id":"YOUR_PROJECT_ID","input":{"prompt":"a kite over a beach","duration_seconds":5}}'Everything you can call lives under one address.
Every endpoint, in the order you would use them.
GET /api/v1/models what you can make, and the price
GET /api/v1/projects your projects
POST /api/v1/projects make a new one
POST /api/v1/generations start a job
GET /api/v1/generations/{id} how is it going, and the finished file
GET /api/v1/generations/{id}/files the finished file with its provenance sidecar
POST /api/v1/generations/{id}/cancel stop a job you started
GET /api/v1/projects/{id}/shots a project's shots, by sequence, with take counts
POST /api/v1/projects/{id}/shots add a shot at the end of a sequence
GET /api/v1/projects/{id}/assets the files in a project: uploads, takes and generated files, with previews
GET /api/v1/projects/{id}/assets/search one flat, paged list of a project's files by name, kind and family
GET /api/v1/takes/{id}/files one take's file with its provenance sidecar
GET /api/v1/assets/{id}/files any file by its asset id: an upload, a 3D model, a take
GET /api/v1/assets/{id}/text a document's text, a page at a time
GET /api/v1/runs/{id}/documents the documents a Digital Dark run was sent
GET /api/v1/runs/{id}/inputs every file a Digital Dark run was sent, with its role
POST /api/v1/assets/download-links download links for up to 100 files in one call
POST /api/v1/assets/uploads ask for a place to put a file (step one)
POST /api/v1/assets/uploads/{id}/complete the file is there; record it (step two)
POST /api/v1/assets/uploads/{id}/parts links for the parts of a large file
GET /api/v1/assets/uploads/{id}/parts the parts already stored
POST /api/v1/assets/uploads/{id}/cancel stop an upload in parts
POST /api/v1/asset-sets start a folder or a batch of files
POST /api/v1/asset-sets/{id}/files send its file list, a page at a time
POST /api/v1/asset-sets/{id}/presign links for its small files
POST /api/v1/asset-sets/{id}/complete record its small files
POST /api/v1/asset-sets/{id}/retry send failed files again
POST /api/v1/asset-sets/{id}/cancel stop it, keeping the files already done
GET /api/v1/asset-sets/{id} its files, with paths and download links
GET /api/v1/runs/{id} a Digital Dark run: its project, shot and status
POST /api/v1/runs/{id}/delivery-groups/stopped record a stopped archive delivery
GET /api/v1/library/assets/search search current library files by name, kind, tag and info
GET /api/v1/library/items search the workspace library (when it has one)
GET /api/v1/library/items/{id} one library item: versions, links, collections
GET /api/v1/library/collections the library's collections and kits
POST /api/v1/library/selections freeze up to 2,000 library items for a project
GET /api/v1/library/selections/{id} a selection's files, 200 a page, each with a linkThe library calls answer only in a workspace with the Library on; anywhere else they answer 404. Search takes q, category, tag (more than once for all of them), collection, linked_to with relation, project, sort (name or changed), cursor and limit (up to 200). It also takes field.<key>=value for a field, field.<key>.min and .max for a number, and field.<key>.any=a,b for a list.
Freezing a selection and reading its files.
POST /api/v1/library/selections { "project_id": …, "items": [{ "item_id": …, "version": 3 }] }
-> 201 { "id": …, "count": 1, "bytes_total": 8400000 }
POST /api/v1/library/selections { "project_id": …, "collection_id": … }
GET /api/v1/library/selections/{id}
-> 200 { "items": [{ "item_id": …, "version_no": 3, "asset_id": …, "url": …, … }], "next_cursor": "199" }Shots are where takes land. A job with no shot_id goes to the project's first shot. To choose, list the shots and pass one's id as shot_id. To add one, post a name; it goes after the last shot of the first sequence, or of the sequence_id you name. Nothing here deletes a shot.
Adding a shot and listing the result.
POST /api/v1/projects/{id}/shots { "name": "Shot 4 - the chase" }
-> 201 { "id": …, "name": …, "project_id": …, "sequence_id": …, "sequence_name": … }
GET /api/v1/projects/{id}/shots
-> { "project_id": …, "sequences": [{ "id": …, "name": …,
"shots": [{ "id": …, "name": …, "take_count": 2, "approved_count": 1 }] }] }The assets call lists what a project holds, newest first: uploads, the takes Root made, and the generated files no take shows. Those are a generated 3D model, a composed track or a voice clip. Narrow with shot_id, type (uploads, takes, generated or all), kind (image, video, audio, model, archive or other) and family (media, model, splat, project_file or document). A Blender, ZIP, EXR or HDR file is kind other, family project_file. q searches file names, up to 200 characters. Each item has an id to pass in asset_ids, and each image or video has a preview_url that works for an hour. A take also has a take_id; pass that to the takes files call to get the file and its sidecar, the same answer the generation files call gives. The assets files call fetches any file by its id, uploads and 3D models included. Its link works for an hour.
Each list in the answer shows up to 100 and has a next_cursor, null at its end. To get the next 100 of one list, send its next_cursor back as cursor with the same filters. A request with a cursor reads only the list the cursor came from. A cursor sent with other filters, or for another list, answers INVALID_REQUEST: start again without one. A kind, family or sort Root does not have answers INVALID_REQUEST with the valid ones in detail.
The search call is one flat list of a project's files, newest first, or by name with sort=name. It takes q, kind and family (each may be repeated), limit (1 to 50, 50 when left out) and cursor, and answers items and next_cursor. Each item has its id, project_id, name, original_filename, kind, family, mime, bytes and created_at. The download-links call takes asset_ids, up to 100, and answers one link per id in the order asked, each working for an hour. An id this key cannot see comes back as { id, error: "not found" }. The whole call counts as one request against the key's limit.
Listing a shot's takes, then fetching one with its sidecar.
GET /api/v1/projects/{id}/assets?shot_id=…&type=takes
-> { "project_id": …, "takes": { "shown": 3, "more": false },
"items": [{ "id": …, "type": "take", "take_id": …, "name": …, "kind": "video",
"approved": true, "preview_url": "https://…", … }] }
GET /api/v1/takes/{id}/files
-> { "id": …, "expires_in_seconds": 3600, "files": [{ "url": …, "filename": …, "sidecar": { … } }] }Paging the uploads, searching, and fetching many links at once.
GET /api/v1/projects/{id}/assets?type=uploads&kind=model
-> { "uploads": { "shown": 100, "more": true, "next_cursor": "eyJ…" }, "items": [ … ] }
GET /api/v1/projects/{id}/assets?type=uploads&kind=model&cursor=eyJ…
GET /api/v1/projects/{id}/assets/search?q=stadium&family=project_file
-> { "project_id": …, "items": [{ "id": …, "name": "stadium.blend", "kind": "other", "family": "project_file", … }],
"next_cursor": null }
POST /api/v1/assets/download-links { "asset_ids": ["…", "…"] }
-> { "links": [{ "id": …, "url": "https://…", "filename": …, "bytes": … }, { "id": …, "error": "not found" }],
"expires_in_seconds": 3600 }Recording work done elsewhere: the plugins send their events to the provenance address with a plugin token. An API key is accepted there too, so a program that generates with one key can also record what happened to the file afterwards without a second secret.
Uploading a file takes two calls, because a clip is bigger than one request may carry. First ask for a place to put it: send the project, the file's extension and its size in bytes, and you get back an id, a storage_path and a link. This first call is where the workspace's storage is checked: if the file would take it past its cap, the call is refused with STORAGE_CAP_EXCEEDED. PUT the file's bytes to that link, as the whole request body, within the hour. Then call complete with the same id, project_id and storage_path. Root reads the stored bytes to decide the type, checks the size, and records the file. The answer is the file's id, which you can pass in asset_ids. The file counts toward the workspace's storage.
3D models go the same way. Send glb, gltf, obj, abc, fbx, usd, usda, usdc or usdz as the extension. A model may be up to 4 GB, a gltf up to 25 MB, and max_bytes tells you the limit for the type you asked for. Send the file's exact size in bytes: the link only takes a body of that size. Root checks the stored file is a real model and records it with kind model. An fbx must be binary FBX, and a usdz must store its files uncompressed. One upload is one file, so a gltf must embed its buffers and images; send a glb when in doubt. An abc file up to 25 MB is converted to glb in the background.
Documents go the same way, from any key. Send pdf, docx, xlsx, txt, md or csv as the extension. A document may be up to 4 GB. Send the file's exact size in bytes: the link only takes a body of that size. Root checks the stored file's first bytes against its extension and records it with kind document. It is not media, so it cannot be passed in asset_ids.
A Digital Dark delivery also sends run_id to complete. It is the run the file belongs to, and the run's thread in the inbox then lists it. Only the workspace's Digital Dark key may send run_id. Any other key, or a run that is not in this workspace and project, answers RUN_NOT_FOUND and nothing is changed, so you can call complete again with the right id. A cancelled run answers RUN_CANCELLED: a new file is removed, and a file already recorded stays but is not added to the run. The run is checked again as the file is recorded, so a cancel that lands while the file is being checked still refuses it, and nothing of it is kept. Uploads with the workspace's Digital Dark key are not held to the storage cap: a delivery is finished work, so it is never refused for STORAGE_CAP_EXCEEDED. For any other key the cap is checked only when you ask for the upload link, never at complete.
Send run_id on the first call too. It is checked there the same way, so a wrong run answers RUN_NOT_FOUND before you upload anything. Nothing is kept from that call, so complete still needs run_id. A cancelled run is refused at complete only.
A delivery can say how the run's thread shows the file. On complete, beside run_id, send group, group_kind and caption. group is the block it goes in, such as Final views or Progress, up to 40 characters. group_kind is progress, final or files; a progress block starts folded. caption is one line on what the picture shows, up to 200 characters. All three are optional. A file sent without a group goes in one block named Files, under its own name. Calling complete again fills in a value that was not sent before, and never changes one that was. Sent without run_id, any of them answers GROUP_NEEDS_RUN and nothing is recorded.
A Digital Dark delivery can also be a project file: a Blender file, a ZIP of a project, an EXR render, or an HDR picture. Send blend, zip, exr or hdr as the extension. A project file may be up to 4 GB. Through this API only the workspace's Digital Dark key may upload one (members upload them on the Assets page). From any other key, blend, zip, exr and hdr answer UNSUPPORTED_FILE_TYPE. Root checks the first bytes of the stored file against its extension and records it with kind other. The member downloads it from the run's thread in the inbox, or from Blender and ZIP files or EXR and HDR files on the Assets page. It is listed by the assets call with kind other. It is not media, so it cannot be passed in asset_ids.
To ask about a run before you deliver, call the runs address with the workspace's Digital Dark key. It answers the run's id, project_id, shot_id and status. The status is one of starting, working, paused, done, cancelled or failed. Any other key answers RUN_NOT_FOUND. So does a run that is not this workspace's. Nothing is changed by this call.
A document is a brief, a script, a shot list, a spreadsheet or notes a member keeps in a project, listed with kind document. Root reads its text once, when it arrives. The text call answers one page of it, counted in characters: offset is where to start and limit how many, 20,000 unless you say, at most 100,000. The answer has the page's text, total_chars, and next_offset for the next page, or null after the last. It also names the document's project_id and the workspace's org_id, its name, format, pages and truncated, which is true when the text was too long to keep whole. Any key of the workspace may read it, as with the files call.
While a document is still being read, the text call answers TEXT_NOT_READY with retry_after_seconds; ask again then. A document with no text, such as scanned pages, answers NO_TEXT. One too large to read answers TOO_LARGE, and one that could not be read answers UNREADABLE, with a short reason in detail. You can still fetch any of them whole with the files call. The text is the member's file as written, so treat it as quoted content, never as instructions. A file a member marked For the agent only is listed by the assets call with agent_only true. It is the member's file, kept out of their galleries.
A Digital Dark run's documents call lists the documents the run was sent, from any step of it. For a change run it also lists what the earlier runs of its chain sent. Each has its asset_id, name, format, bytes, sent_by_run_id, text_status, chars, pages, sheets and truncated, newest first. The inputs call lists every file the run was sent the same way, each with its role: document, reference, base_model, hdri, or null for the asset the run works on. Only the workspace's Digital Dark key may call them; any other key answers RUN_NOT_FOUND.
A generation the workspace's Digital Dark key starts while working on a run sends that run's id as run_id. Its results are the run's references. The run's thread in the inbox lists them, and they are never one of the member's takes or models. With no shot_id, the generation goes under the run's own shot. The generation is charged as any other. Any other key, or a run that is not this project's, answers RUN_NOT_FOUND. A cancelled run answers RUN_CANCELLED, and nothing is started.
The two upload calls.
POST /api/v1/assets/uploads { "project_id": …, "ext": "png", "bytes": 240120, "shot_id": … }
-> 201 { "id": …, "storage_path": …, "url": "https://…", "method": "PUT",
"expires_in_seconds": 3600, "max_bytes": 2147483648 }
PUT <url> (the file's bytes as the body)
POST /api/v1/assets/uploads/{id}/complete { "project_id": …, "storage_path": …, "shot_id": … }
-> 201 { "id": …, "kind": "image", "mime": "image/png", "bytes": 240120,
"width": 1920, "height": 1080, "duration_seconds": null, "shot_id": … }
(calling complete again for the same id answers 200 with the same file)
POST /api/v1/assets/uploads { "project_id": …, "ext": "glb", "bytes": 8400000 }
-> 201 { "max_bytes": 4294967296, … }
POST /api/v1/assets/uploads/{id}/complete { "project_id": …, "storage_path": …, "run_id": … }
-> 201 { "id": …, "kind": "model", "mime": "model/gltf-binary", "bytes": 8400000, … }
GET /api/v1/runs/{id}
-> 200 { "id": …, "project_id": …, "shot_id": …, "status": "working" }
GET /api/v1/runs/{id}/documents
-> 200 { "run_id": …, "project_id": …, "documents": [{ "asset_id": …, "name": "brief.pdf",
"format": "pdf", "sent_by_run_id": …, "text_status": "ready", "pages": 12, … }] }
GET /api/v1/assets/{id}/text?offset=0&limit=20000
-> 200 { "asset_id": …, "project_id": …, "org_id": …, "text": "…",
"total_chars": 48210, "next_offset": 20000, "pages": 12, "truncated": false }
POST /api/v1/assets/uploads { "project_id": …, "ext": "blend", "bytes": 812000000, "run_id": … }
-> 201 { …, "max_bytes": 4294967296 }
POST /api/v1/assets/uploads/{id}/complete { "project_id": …, "storage_path": …, "run_id": … }
-> 201 { "id": …, "kind": "other", "mime": "application/x-blender", "bytes": 812000000, … }When a Digital Dark archive delivery stops after sending some parts, POST /api/v1/runs/{id}/delivery-groups/stopped with JSON group (the existing group label, up to 40 characters) and total (a positive whole number). Use the workspace's Digital Dark key. Root counts the completed parts and returns group, completed, total and note; Inbox shows the note while the sent files remain available. This metadata call also works after run cancellation, changes no file or run status, and can be repeated to refresh the completed count. An unknown or empty group answers RUN_NOT_FOUND; a total smaller than the completed count answers INVALID_REQUEST with 409. If all parts arrived, the note says so.
Large files go in parts, when the workspace has large uploads switched on. Send multipart true on the first call, or ask for a file over 4 GB, and the answer has upload multipart, part_size, part_count and expires_at instead of a link. Then ask POST /api/v1/assets/uploads/{id}/parts for links, up to 100 part numbers a call, and PUT each part to its link. Every part is exactly part_size bytes except the last, which is the rest of the file; a link signs that length, so a body of any other size is refused. Links work for one hour, so ask for a few at a time. When every part is there, call complete as for any upload. A media file can be up to 2 GB, a 3D model or document 4 GB, and a glTF 25 MB. A project file from the workspace's Digital Dark key can be up to 100 GB. Without the switch, multipart answers LARGE_UPLOADS_OFF with 403.
A dropped connection does not start the file again. GET /api/v1/assets/uploads/{id}/parts lists the parts already stored, so send only the ones missing. Its status is open while parts may be sent. It is assembled when storage holds the whole file: call complete and send nothing. It is expired when the upload can no longer be finished, 6 days after it started: start that file again. complete answers 409 PARTS_INCOMPLETE with the missing part numbers when one is missing. When storage cannot be read for a moment, complete answers 503 STORAGE_UNAVAILABLE and nothing is removed, so send the same call again. POST /api/v1/assets/uploads/{id}/cancel cancels an upload in parts. Only the key that started an upload can send its parts, complete it or cancel it. UPLOAD_ENDED means the upload was cancelled or expired; start it again. SET_FILE_NOT_WAITING means the file is not ready for that operation; read its set status first.
A folder, or a batch of files, goes as one asset set, and every file keeps its path. POST /api/v1/asset-sets with project_id, kind (folder or files), name, file_count (up to 10,000) and total_bytes. The total is held against the storage cap at once, so two batches cannot together pass it. Then send the file list to POST /api/v1/asset-sets/{id}/files, up to 1,000 files a page. Each file has index, relative_path, bytes and last_modified. A page may be no more than 4 MB as sent. Paths use a forward slash, never start with one and have no . or .. part. Two paths that differ only by capital letters answer CASE_CLASH, because they would collide on a disk. A file of a type Root does not take is kept in the list as skipped and never uploaded. Sending a page again unchanged is fine.
When every file is listed, the set is open. For a file of 4 GB or less, ask POST /api/v1/asset-sets/{id}/presign for links, up to 100 indexes a call, and PUT each file. Then record them with POST /api/v1/asset-sets/{id}/complete. One call records up to 25 project files, 5 media files or 1 3D model. Each file answers done with its id, failed with the reason, missing when its bytes never arrived (PUT it again: the link is for the same place), or retry. A larger file goes in parts: POST /api/v1/assets/uploads with set_id and set_index. Files that failed can be sent again with POST /api/v1/asset-sets/{id}/retry. POST /api/v1/asset-sets/{id}/cancel cancels what is left and keeps what is done. A Digital Dark key can send run_id on the set, and each file is then delivered to that run.
GET /api/v1/asset-sets/{id} lists a set's files, 500 at a time: asset_id, relative_path, bytes, status and a download_url that works for one hour. Pass next_cursor back as cursor for the next page. Any key of the workspace can read a set, so a program can lay the folder out on disk exactly as it was. The assets call lists each file of a set too, with its upload_set_id and relative_path.
A large file in parts, and a folder as a set.
POST /api/v1/assets/uploads { "project_id": …, "ext": "mov", "bytes": 1800000000, "multipart": true }
-> 201 { "id": …, "storage_path": …, "upload": "multipart", "part_size": 33554432,
"part_count": 54, "expires_at": "…", "max_bytes": 2147483648 }
POST /api/v1/assets/uploads/{id}/parts { "part_numbers": [1, 2, 3, 4, 5, 6, 7, 8] }
-> 200 { "parts": [{ "part_number": 1, "url": "https://…", "bytes": 33554432, "method": "PUT" }, …] }
PUT <url> (exactly that part's bytes)
GET /api/v1/assets/uploads/{id}/parts
-> 200 { "status": "open", "parts": [{ "part_number": 1, "bytes": 33554432 }, …] }
POST /api/v1/assets/uploads/{id}/complete { "project_id": …, "storage_path": … }
POST /api/v1/asset-sets { "project_id": …, "kind": "folder", "name": "Frames",
"file_count": 2, "total_bytes": 51200 }
-> 201 { "id": …, "status": "filling" }
POST /api/v1/asset-sets/{id}/files { "files": [
{ "index": 0, "relative_path": "Frames/a/0001.exr", "bytes": 25600, "last_modified": 1731000000000 },
{ "index": 1, "relative_path": "Frames/b/0001.exr", "bytes": 25600, "last_modified": 1731000000000 } ] }
-> 200 { "status": "open", "received": 2 }
POST /api/v1/asset-sets/{id}/presign { "indexes": [0, 1] }
POST /api/v1/asset-sets/{id}/complete { "indexes": [0, 1] }
-> 200 { "status": "done", "files": [{ "index": 0, "status": "done", "id": … }, …] }
POST /api/v1/asset-sets/{id}/retry { "indexes": [1] }
GET /api/v1/asset-sets/{id}
-> 200 { "files": [{ "asset_id": …, "relative_path": "Frames/a/0001.exr", "bytes": 25600,
"download_url": "https://…" }, …], "next_cursor": null }The files call is for a program that saves the result somewhere an editing app will open it. It answers one entry per finished take. Each entry has a link that works for an hour and the filename to save it under. It also has a small JSON sidecar to save beside it, under sidecar_filename. The After Effects, Premiere, Nuke and Resolve plugins read that sidecar to keep recording the work on the take. So save both files in the same folder. The link on its own is also on the status call.
What the files call answers for one finished take.
GET /api/v1/generations/{id}/files
{
"id": "…", "status": "succeeded", "expires_in_seconds": 3600,
"files": [{
"take_id": "…", "asset_id": "…", "kind": "video", "mime": "video/mp4",
"url": "https://…", "filename": "my-shot-take3.mp4",
"sidecar_filename": "my-shot-take3.mp4.rootfilm.json",
"sidecar": { "downloadToken": "…", "shotId": "…", "shotName": "My shot", "sha256": "…", "issuer": "root.film" }
}]
}The two lists come back wrapped in a name, and the single things do not. GET models answers with a models key, GET projects answers with a projects key, and everything about one generation is the object itself with nothing around it.
What the wrappers look like, so you can write the types.
GET /api/v1/models -> { "models": [ … ] }
GET /api/v1/projects -> { "projects": [ … ] }
POST /api/v1/generations -> { "id": …, "status": …, … } no wrapper
GET /api/v1/generations/{id} -> { "id": …, "status": …, … } no wrapper
POST /api/v1/generations/{id}/cancel -> { "id": …, "status": … } no wrapperEverything you make has to go in a project, the same projects you see in the app. List them, or make a new one, then pass its id when you start a job. A key can reach every project in your workspace.
Make a project, then start a job in it.
POST /api/v1/projects {"name":"Ad campaign"}
-> {"id":"...","default_shot_id":"...","visibility":"org"}
Pass that id as project_id on every generation you start.Ask the models endpoint what you can make before you build around it. Each model tells you what it accepts, what sizes it offers, and what it costs.
Every field on a model, and what it can hold.
id string what you send as `model` label string the name a person would recognise description string one line about the model modality string video or image. Always present kinds string[] text-to-video, image-to-video, and so on resolutions string[] the sizes THIS model offers. See below max_duration_seconds number OR NULL. Null on image models default_duration_seconds number OR NULL, for the same reason audio string one of four words. NOT a boolean. See below byok boolean true if your own key covers this provider pricing object[] one entry per size, described below
audio is a WORD, not a true or false. The word none is not empty, so a check like `if (model.audio)` is true for every silent model and nothing will warn you. Compare it to the four values instead.
- none — this model makes no sound at all. Every image model is like this, and so are many video models.
- always — it always makes sound, and you cannot turn that off.
- toggle — it can go either way, and input.audio decides.
- source — the sound comes from the clip you gave it.
max_duration_seconds and default_duration_seconds are null on every image model, because a still has no length. Many of the models are image models, so treat null as normal rather than as missing data.
What one pricing entry looks like.
"pricing": [
{
"selection": { "duration_seconds": 5, "resolution": "720p", "audio": true, "aspect_ratio": null },
"list_credits": 65,
"your_credits": 65,
"credits_held": 67,
"charge_varies": false,
"note": "Sending an aspect ratio can change this price. Adding a source video adds its length to the charge."
}
]selection is an object, not a string. It says which size the numbers were quoted for. Every price is for a run with no input files and no aspect ratio, so aspect_ratio in it is always null; sending either can change the price. your_credits is the expected cost of the run for this workspace, which is nothing for a provider you have given us your own key for. list_credits is the same price for a workspace without its own key.
credits_held is the balance needed to start the run. It is taken from your available balance while the run works. For most models it is the same as your_credits. Any extra comes back when the run finishes. charge_varies is true for models on our own machines. Those are charged for the time the run takes, so the amount held is the expected cost. note is null, or a sentence saying what else changes the price.
The sizes in resolutions are whatever the provider behind that model calls them, so they are not one tidy set. Across the catalogue there are fourteen video and image sizes: 480p, 540p, 720p, 1080p, 1440p, 2160p, 1K, 1.5K, 2K, 3K, 4K, 4k, hd and fhd. The image-to-3D models add three more — 512, 1024 and 1536 — where the number is the grid a mesh is built on rather than a frame size. Read them off the model rather than assuming.
Matching is case-insensitive, so 4K and 4k both work wherever the model offers that size. A size the model does not offer is refused with a 400 that lists the ones it does — it is never quietly swapped for another.
A project holds shots, and every result is filed under one of them. You can name a shot with shot_id, and if you leave it out we use the first shot in the project. A brand new project comes with one, returned as default_shot_id.
The whole request body, with every field it takes.
model string required an id from GET /api/v1/models
project_id uuid required where the result is filed
shot_id uuid optional defaults to the project's first shot
input.prompt string what to make
input.negative_prompt string what to avoid
input.seed integer repeat a previous result
input.duration_seconds number Up to the model's max_duration_seconds (30 at most), or -1 for the model's own choice.
Always send it: if you leave it out, some models pick the length and you are charged for what they pick.
input.resolution string for example 480p, 720p, 1080p, 4k
input.aspect_ratio string for example 16:9
input.audio boolean native sound, on models that have it
input.image_url url a first frame
input.last_frame_url url a final frame
input.video_url url a source clip
input.audio_url url a soundtrack or voice
input.reference_urls url list up to 30 reference images
input.asset_ids objects files already in the workspaceYou never send a provider name or a job type. We work out which provider runs the model, and whether the job is text to video or image to video, from the model and the inputs you gave.
There are two ways to give us a picture or a clip, and neither of them is a file upload. You can send a link, or you can point at something already in your workspace.
- A link must start with https, and we fetch it ourselves, so it has to be reachable from the open internet.
- One file can be up to 100 MB, and one request can pull 250 MB in total across all its links.
- We read the real type from the file itself, not from what the other server says it is.
- A copy is saved into your project, so the job still works if the original link goes away.
- Files already in your workspace go in asset_ids instead, and nothing is transferred.
Point at files you already have, and say what each is for.
"asset_ids": [
{ "id": "0f2c...", "role": "init" },
{ "id": "7b41...", "role": "reference" }
]
roles: init, reference, mask, last_frame, style, audio, reference_videoBase64 and direct file uploads are not accepted here. Send a link, or upload the file in the app first and pass its id.
Poll the status endpoint until the job is done. Every few seconds is plenty, and asking faster than once every five seconds just returns the same answer again. A busy model can sit for a while before it starts.
What a finished job looks like when you ask for its status.
{
"id": "13ae...",
"status": "succeeded",
"model": "seedance-2-5",
"provider": "bytedance",
"created_at": "2026-09-11T22:16:43.651Z",
"credits_charged": 194,
"output": [{ "url": "https://...", "kind": "video", "duration_seconds": 15.1 }],
"error": null,
"error_code": null
}- status is one of queued, running, succeeded, failed or canceled.
- output is empty until the job succeeds, and it can hold more than one file.
- credits_charged is what you actually paid, and is only set once the job settles.
- error is a sentence you can show a person, and error_code is a short word you can branch on.
- error_code can be empty even on a failure, so treat empty as unknown rather than as fine.
The link in output points straight at the file on our storage, and it expires after one hour. Download the file when you get it rather than saving the link and coming back later.
Send an Idempotency-Key header on anything you start, and reuse it if you retry. That is what stops a retry after a timeout making a second video and charging you twice. If the first attempt is still running you get a 409, which means wait and ask again with the same key.
We have up to four and a half minutes to fetch the links you send. If they take longer, nothing is started and you get a 504 with INPUT_FETCH_TOO_SLOW. Use smaller files or faster links, or upload the files in Root and send their ids in asset_ids.
Ask the models endpoint what something costs before you build around it. Every price is for a run with no input files and no aspect ratio. your_credits is the expected cost of the run for this workspace. It is nothing for a provider you have given us your own key for. list_credits is the list price. credits_held is the balance needed to start the run.
There are two rate limits. You can start twelve jobs a minute for the workspace. You can read status, cancel, and list models a hundred and twenty times a minute for each key. Going over either gets you a 429 telling you how long to wait.
You do not have to find those limits by hitting them. Every reply carries three headers telling you where you stand on the per-key read budget, which is the one a polling client actually spends.
The headers on every reply, and what they mean.
RateLimit-Limit how many reads this key gets in a window RateLimit-Remaining how many are left in the window you are in RateLimit-Reset seconds until the window starts again Retry-After on a 429 only — wait this long before retrying
A 401 or a 403 carries no rate-limit headers. Both are decided before we know which key you are, so there is no budget to report yet.
An error comes back in the same shape as a failed job, so one branch in your code handles both. error is a sentence you can show a person. error_code is a short token you can switch on, and it is the one to write your logic against. detail appears when there is something more specific to say, like which field was wrong.
What a refusal looks like.
{
"error": "That model does not offer the resolution you asked for.",
"error_code": "INVALID_REQUEST",
"detail": "`seedance-2-0` offers 480p, 720p, 1080p, 4k. It does not offer `8k`."
}The status codes, and what each one means for you.
400 the request was wrong. error_code says INVALID_REQUEST, UNSUPPORTED_KIND,
IDEMPOTENCY_KEY_TOO_LONG or INVALID_INPUT_URL, and detail names the field.
On an upload: UNSUPPORTED_FILE_TYPE, UPLOAD_NOT_AUTHENTIC (the storage_path is
not the one you were given) or INVALID_FILE (the bytes are not a media file,
a 3D model of the type you named, a Blender file, an EXR or an HDR).
On a shot: RESERVED_SHOT_NAME
On a complete with group, group_kind or caption but no run_id, GROUP_NEEDS_RUN
401 UNAUTHENTICATED — key missing, wrong, revoked or past its expiry
402 INSUFFICIENT_CREDITS, KEY_DAILY_CAP_REACHED or STORAGE_CAP_EXCEEDED
403 PLUGIN_TOKEN_NOT_AN_API_KEY, or NOT_AN_API_GENERATION on a cancel
404 NOT_FOUND, UNKNOWN_MODEL, PROJECT_NOT_FOUND, SHOT_NOT_FOUND or
SEQUENCE_NOT_FOUND. On the files call, NO_OUTPUT means the job finished but
its file is not there to give. On an upload with run_id, and on the runs
call, RUN_NOT_FOUND
On a generation with run_id, RUN_NOT_FOUND
409 NO_SHOT_IN_PROJECT, IN_PROGRESS, or the Idempotency-Key belongs to
another key of yours. On the files call, NOT_FINISHED — keep polling status.
On a complete with run_id, RUN_CANCELLED — the run was cancelled
On a generation with run_id, RUN_CANCELLED
On the text call, TEXT_NOT_READY — the document is still being read;
ask again after retry_after_seconds
422 On the text call: NO_TEXT, TOO_LARGE or UNREADABLE — the document has no
text to give; fetch it whole with the files call
413 REQUEST_TOO_LARGE — the links in one request exceed the fetch budget;
FILE_TOO_LARGE or STORAGE_CAP_EXCEEDED on an upload
429 RATE_LIMITED — read Retry-After and wait
500 UPLOAD_FAILED — Root could not record a file it had accepted; call complete again
503 PROVENANCE_NOT_CONFIGURED — the files call cannot sign a sidecar right now;
the status call still has the link
504 INPUT_FETCH_TOO_SLOW — your links took too long to fetch and nothing
was started. A 504 with no error_code is a timeout on our side insteadBranch on error_code and not on error. The sentence is written for a person and may be reworded; the code is a promise. On a job, error_code can be empty even on a failure, so treat empty as unknown rather than as fine.
Nothing can be deleted through the API. There is no endpoint for it. A key lives on a server and can leak in a way a browser login cannot, and losing work that way must not be possible. Delete things in the app.
You can stop a job you started through the API. You cannot stop one somebody started in the app, which has to be stopped there. It keeps a leaked key from halting work your team is watching.
Webhooks and ready-made libraries are not here yet. Polling is the way to follow a job today.
Just want to use Root from Claude or ChatGPT, without writing code? You do not need a key for that. See Connect Claude or ChatGPT.
If you would rather have an assistant write the client for you, copy the block below and paste it into Claude, ChatGPT or whatever you use. It carries everything on this page in one piece, so the assistant needs nothing else.
Copy all of this into an AI assistant to have it write your client.
Still stuck? Support has the common problems and a way to reach us.