Jobs and polling
Anything that takes real time, rendering a video, transcribing audio, generating an image or a clip, runs asynchronously. The endpoint that starts the work returns a job id right away, and you poll that job until it finishes. This page explains the job lifecycle and the polling loop once, so every async endpoint behaves the same way for you.
How it works
- Call an endpoint that starts work, for example
POST /export. It returns ajob_id. - Poll
GET /jobs/{id}until the job reaches a terminal status. - When the status is
completed, read the output fromresult_data. When it isfailed, readerror.
The job id is the only handle you need. You do not keep the connection open or wait on the first request; the render runs on Rendley’s workers while you poll.
Job status
A job moves through these statuses. Three of them are terminal: once a job is completed, failed, or canceled, it will not change again.
| Status | Terminal | Meaning |
|---|---|---|
queued | no | Accepted and waiting for a worker. |
processing | no | A worker is running the job. |
completed | yes | Finished successfully. Read result_data. |
failed | yes | Did not finish. Read error. |
canceled | yes | Canceled by you with DELETE /jobs/{id}. |
The job object
GET /jobs/{id} returns a job in the standard data envelope:
{
"data": {
"id": "job_8f2c...",
"type": "export_video",
"status": "completed",
"input_data": "{\"project_id\":\"...\"}",
"result_data": "{\"storage_url\":\"https://...\",\"media_id\":\"...\"}",
"error": null,
"acknowledged": false,
"source_type": "api",
"source_id": "..."
}
}
idstringThe job id. Use it to poll, cancel, or fetch the job.
typestringWhat the job does, e.g. export_video, transcription, generate_image, generate_video.
statusstringOne of queued, processing, completed, failed, canceled.
input_datastringJSON-encoded string of the request that created the job.
result_datastring | nullJSON-encoded string with the output. Null until the job is completed.
errorstring | nullA short error code or message. Null unless the job failed.
acknowledgedbooleanWhether the job has been marked as seen. Optional, for your own bookkeeping.
source_typestringWhere the job came from. Jobs you start over the API are api.
source_idstringAn id that ties the job back to its origin, such as a project id.
input_data and result_data are JSON encoded as strings, not nested objects. Parse them before reading fields:
const job = (await res.json()).data;
const result = job.result_data ? JSON.parse(job.result_data) : null;
const fileUrl = result?.storage_url;
#Start a job
/v1/exportMost async endpoints return a job_id. The export endpoint is the canonical example.
Billable work, like exports and AI tools, has a matching .../cost endpoint (for example POST /export/cost) that returns the credit price for the exact request without starting it. Call it first if you want to confirm the cost before you commit.
curl -X POST "https://api.rendley.com/v1/export" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "prj_123",
"settings": {
"target_resolution": "1080p",
"codec": "h264"
}
}'const res = await fetch("https://api.rendley.com/v1/export", {
method: "POST",
headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify({
"project_id": "prj_123",
"settings": {
"target_resolution": "1080p",
"codec": "h264"
}
}),
});
const { data } = await res.json();import requests
res = requests.post(
"https://api.rendley.com/v1/export",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"project_id": "prj_123",
"settings": {
"target_resolution": "1080p",
"codec": "h264"
}
},
)
data = res.json()["data"]$ch = curl_init("https://api.rendley.com/v1/export");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer YOUR_API_KEY", "Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["project_id" => "prj_123", "settings" => ["target_resolution" => "1080p", "codec" => "h264"]]));
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true)["data"];package main
import (
"fmt"
"io"
"net/http"
"bytes"
)
func main() {
body := bytes.NewBufferString(`{
"project_id": "prj_123",
"settings": {
"target_resolution": "1080p",
"codec": "h264"
}
}`)
req, _ := http.NewRequest("POST", "https://api.rendley.com/v1/export", body)
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
fmt.Println(string(b))
}require "net/http"
require "json"
uri = URI("https://api.rendley.com/v1/export")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_API_KEY"
req["Content-Type"] = "application/json"
req.body = { project_id: "prj_123", settings: { target_resolution: "1080p", codec: "h264" } }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)["data"]The project to render.
settingsExport settings.
e.g. 1080p, 720p, 4k.
e.g. h264, h265.
Response
{
"data": {
"job_id": "job_8f2c..."
}
}#Poll the job
/v1/jobs/{id}Fetch the job on an interval until status is terminal. A few seconds between polls is plenty; there is no benefit to polling faster than once per second. For long renders, back off to every 5 to 10 seconds.
Polling a job by its id always works, regardless of how the job was created.
# Poll once. Repeat until status is completed, failed, or canceled.
curl https://api.rendley.com/v1/jobs/job_8f2c... \
-H "Authorization: Bearer YOUR_API_KEY"async function waitForJob(jobId, apiKey) {
const terminal = ["completed", "failed", "canceled"];
while (true) {
const res = await fetch(`https://api.rendley.com/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const { data: job } = await res.json();
if (terminal.includes(job.status)) return job;
await new Promise((r) => setTimeout(r, 3000)); // wait 3s, then poll again
}
}
const job = await waitForJob(jobId, "YOUR_API_KEY");
if (job.status === "completed") {
const { storage_url } = JSON.parse(job.result_data);
console.log("Done:", storage_url);
} else {
throw new Error(job.error ?? "job did not complete");
}import time, json, requests
def wait_for_job(job_id, api_key):
terminal = {"completed", "failed", "canceled"}
while True:
res = requests.get(
f"https://api.rendley.com/v1/jobs/{job_id}",
headers={"Authorization": f"Bearer {api_key}"},
)
job = res.json()["data"]
if job["status"] in terminal:
return job
time.sleep(3) # wait 3s, then poll again
job = wait_for_job(job_id, "YOUR_API_KEY")
if job["status"] == "completed":
result = json.loads(job["result_data"])
print("Done:", result["storage_url"])
else:
raise RuntimeError(job.get("error") or "job did not complete")Read the result
When status is completed, parse result_data. The shape depends on the job type.
Export jobs
Export jobs return a direct download URL:
{
"storage_url": "https://storage.rendley.com/exports/...mp4",
"media_id": "med_..."
}
Download the file from storage_url. These links are time limited, so fetch the file soon after the job completes rather than storing the URL for later.
#AI generation jobs (image, video, audio)
/v1/uploadsAI generation jobs return a media_id and file_hash, not a direct download URL:
{
"media_id": "med_abc123",
"file_hash": "sha256_def456..."
}To get a presigned download URL for the generated file, query the uploads endpoint with the file hash. The storage_url in the response is a time-limited presigned URL. Fetch the file promptly.
curl "https://api.rendley.com/v1/uploads" \
-H "Authorization: Bearer YOUR_API_KEY"const res = await fetch("https://api.rendley.com/v1/uploads", {
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
const { data } = await res.json();import requests
res = requests.get(
"https://api.rendley.com/v1/uploads",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]$ch = curl_init("https://api.rendley.com/v1/uploads");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer YOUR_API_KEY"]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true)["data"];package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, _ := http.NewRequest("GET", "https://api.rendley.com/v1/uploads", nil)
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
fmt.Println(string(b))
}require "net/http"
require "json"
uri = URI("https://api.rendley.com/v1/uploads")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_API_KEY"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)["data"]Response
{
"data": {
"storage_url": "https://storage.rendley.com/uploads/...png",
"media_id": "med_abc123"
}
}Transcription jobs
Transcription jobs return the transcript directly in result_data:
{
"language": "en",
"text": "Hello, welcome to our product demo...",
"words": [
{ "word": "Hello", "start": 0.0, "end": 0.42 },
{ "word": "welcome", "start": 0.5, "end": 0.92 }
]
}
AI tools and models
All AI generation endpoints follow the same request shape:
{
"project_id": "prj_123",
"model_id": "nano-banana",
"params": { "prompt": "A sunset over mountains" }
}
project_idstringRequiredThe project to associate the generated file with.
model_idstringOptionalWhich model to use. Optional, each action has a default.
paramsobjectOptionalModel-specific parameters. The schema varies per model.
Default models
Every action has a default model that is used when you omit model_id. Even though the field appears optional in the schema, a model is always selected and the default fills in automatically.
| Action | Default model | Description |
|---|---|---|
| Image generation | nano-banana | General-purpose, fast |
| Video generation | kling-v2.6 | General-purpose default |
| Text-to-speech | eleven-labs-tts | ElevenLabs voices |
| Transcription | eleven-labs-speech-to-text | Speech-to-text |
| Music generation | eleven-labs-music | Short instrumental clips |
| Sound effects | eleven-labs-sound-effect | Sound effect generation |
#Discover models and parameters
/v1/ai/toolsEach model accepts different parameters in the params object. Call this endpoint to see every available model and its parameter schema.
Always call GET /ai/tools to discover the current models and their schemas. Models and parameters may change over time.
curl "https://api.rendley.com/v1/ai/tools" \
-H "Authorization: Bearer YOUR_API_KEY"const res = await fetch("https://api.rendley.com/v1/ai/tools", {
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
const { data } = await res.json();import requests
res = requests.get(
"https://api.rendley.com/v1/ai/tools",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]$ch = curl_init("https://api.rendley.com/v1/ai/tools");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer YOUR_API_KEY"]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true)["data"];package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, _ := http.NewRequest("GET", "https://api.rendley.com/v1/ai/tools", nil)
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
fmt.Println(string(b))
}require "net/http"
require "json"
uri = URI("https://api.rendley.com/v1/ai/tools")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_API_KEY"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)["data"]Response
{
"data": [
{
"action": "generate_image",
"models": [
{
"id": "nano-banana",
"name": "Nano Banana",
"description": "General-purpose, fast, cheap",
"schema": {
"type": "object",
"properties": {
"prompt": { "type": "string" },
"aspect_ratio": { "type": "string", "enum": ["1:1", "16:9", "9:16", "4:3", "3:4"] },
"image_inputs": { "type": "array", "items": { "type": "string" } }
},
"required": ["prompt"]
}
}
]
}
]
}Cost estimation
Every AI endpoint has a matching /cost endpoint. Call it with the same request body to get the credit price before committing:
curl -X POST https://api.rendley.com/v1/ai/generate-image/cost \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "project_id": "prj_123", "model_id": "nano-banana", "params": { "prompt": "A sunset" } }'
When a job fails
A failed job has status: "failed" and a short code or message in error. Common causes:
- A media URL in an inline export could not be reached. Every asset referenced by an inline render must be at a permanent, publicly reachable URL. Temporary or signed URLs that expire cause the render to fail.
- The account ran out of credits partway through, or hit a plan limit.
- The input was malformed in a way that only surfaced during processing.
Treat error as a reason to surface to your own logs or users. The job will not retry itself; start a new job once you have fixed the cause.
#Cancel a job
/v1/jobs/{id}Cancel a job that is still queued or processing. The job moves to canceled. A job that has already finished cannot be canceled.
curl -X DELETE "https://api.rendley.com/v1/jobs/{id}" \
-H "Authorization: Bearer YOUR_API_KEY"const res = await fetch("https://api.rendley.com/v1/jobs/{id}", {
method: "DELETE",
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
const { data } = await res.json();import requests
res = requests.delete(
"https://api.rendley.com/v1/jobs/{id}",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]$ch = curl_init("https://api.rendley.com/v1/jobs/{id}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer YOUR_API_KEY"]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true)["data"];package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, _ := http.NewRequest("DELETE", "https://api.rendley.com/v1/jobs/{id}", nil)
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
fmt.Println(string(b))
}require "net/http"
require "json"
uri = URI("https://api.rendley.com/v1/jobs/{id}")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer YOUR_API_KEY"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)["data"]#List your jobs
/v1/jobsYou normally poll a job by the id you got back, but you can also list jobs, for example to reconcile state after a restart.
Listing defaults to jobs created in Rendley (source_type=studio). Jobs you start over the API have source_type=api, so pass that filter to see them.
curl "https://api.rendley.com/v1/jobs" \
-H "Authorization: Bearer YOUR_API_KEY"const res = await fetch("https://api.rendley.com/v1/jobs", {
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
const { data } = await res.json();import requests
res = requests.get(
"https://api.rendley.com/v1/jobs",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]$ch = curl_init("https://api.rendley.com/v1/jobs");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer YOUR_API_KEY"]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true)["data"];package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, _ := http.NewRequest("GET", "https://api.rendley.com/v1/jobs", nil)
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
fmt.Println(string(b))
}require "net/http"
require "json"
uri = URI("https://api.rendley.com/v1/jobs")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_API_KEY"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)["data"]Status codes
These are the codes you will see while starting and polling jobs.
| Status | Meaning |
|---|---|
200 OK | The poll or list call succeeded. Check the job’s status field for progress. |
400 Bad Request | The request body or query was invalid. |
401 Unauthorized | The API key is missing, malformed, or invalid. See Authentication. |
402 Payment Required | Too few credits for a billable operation. |
403 Forbidden | The key is valid but cannot access this resource, or a plan limit was reached. |
404 Not Found | No job with that id belongs to your account. |
429 Too Many Requests | Rate limited. Back off and retry. |
A 4xx here is about the HTTP call itself. A job that was accepted but then failed during processing still returns 200 from GET /jobs/{id}, with status: "failed" and a reason in error.
Full reference
For the field-by-field schema of each job endpoint, see the Jobs reference.