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.
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}}'There are twelve things you can call, and they all live 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 and takes, with previews
GET /api/v1/takes/{id}/files one take's file with its provenance sidecar
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)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: uploads and the takes Root made, newest first. Narrow with shot_id, type (uploads, takes or all) and kind (image, video or audio). 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.
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": { … } }] }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. 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 your storage, 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.
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)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. Forty five of the seventy six are like this.
- 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. Forty nine of the seventy six 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).
On a shot: RESERVED_SHOT_NAME
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
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
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.