Rendley docs

Results and export

Export the result

A completed job means the timeline is ready, not that a file exists. Open the project in the editor, or export it to an MP4.

Export uses the standard job format, where the result sits under output. Agent jobs differ: their fields sit on the job object itself.

Errors

StatusCodeWhat to do
400BAD_REQUESTA field is missing or contradicts another. Sending thread_id without project_id lands here, as does a files entry with no url, storage_url or media_id, and more than 20 files in one request.
400VALIDATION_ERRORA field failed its format check. The fields array names each one.
400AGENT_JOB_REJECTEDThe run could not be started. The message says why.
401UNAUTHORIZEDCheck the API key.
403SUBSCRIPTION_REQUIREDThe agent requires an active subscription.
403PLAN_LIMIT_REACHEDYou omitted project_id and the plan does not allow another project. Delete one, or pass an existing project_id.
404JOB_NOT_FOUNDNo such job, or it is not yours.
409AGENT_PROJECT_BUSYAnother edit is active on this project. Poll, answer, or cancel it. The fields array carries active_job_id and active_job_status.
429AGENT_CONCURRENCY_LIMIT_REACHEDToo many edits running for this account. Wait for one to finish.
429AGENT_CHAR_LIMIT_REACHEDThe plan’s prompt character allowance is used up. It resets with the billing window, or upgrade.

Downloading the video

Start the export with the project_id from the agent job, poll it, then fetch the signed URL the finished job carries.

The polling helpers used below
const API_KEY = "YOUR_API_KEY";
  const API = "https://api.rendley.com/v1";

  const headers = {
    "Authorization": `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  };

  // Both endpoints share these three terminal statuses. They differ only in
  // their in-progress names: queued/processing vs pending/running.
  const TERMINAL = ["completed", "failed", "canceled"];

  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));


  // Generation and export jobs. The finished job carries output.url.
  async function waitForJob(jobId, interval = 5000) {
    let job = null;

    while (job === null || !TERMINAL.includes(job.status)) {
      await sleep(interval);

      const response = await fetch(`${API}/jobs/${jobId}`, { headers });

      if (!response.ok) {
        throw new Error("Job lookup failed: " + response.status);
      }

      const body = await response.json();

      job = body.data;
      console.log("Job status:", job.status);
    }

    if (job.status !== "completed") {
      throw new Error("Job " + job.status + ": " + (job.error || ""));
    }

    return job;
  }


  // Agent jobs. The finished job carries project_id, thread_id and
  // last_message, but no output: export the project to get a file.
  // onPause runs when an interactive run stops to ask something.
  async function waitForAgentJob(jobId, interval = 5000, onPause) {
    let job = null;

    while (job === null || !TERMINAL.includes(job.status)) {
      // This endpoint long-polls. Sleep anyway, so a fast response
      // cannot turn this into a tight request loop.
      await sleep(interval);

      const response = await fetch(`${API}/agent/jobs/${jobId}`, { headers });

      if (!response.ok) {
        throw new Error("Job lookup failed: " + response.status);
      }

      const body = await response.json();

      job = body.data;
      console.log("Edit status:", job.status);

      // Only interactive runs reach this; unattended runs never pause.
      if (job.status === "waiting_input" && onPause) {
        await onPause(job);
      }
    }

    if (job.status !== "completed") {
      throw new Error("The edit did not finish: " + (job.error || job.reason));
    }

    return job;
  }
import time
  import requests

  API_KEY = "YOUR_API_KEY"
  API = "https://api.rendley.com/v1"

  HEADERS = {"Authorization": f"Bearer {API_KEY}"}

  # Both endpoints share these three terminal statuses. They differ only in
  # their in-progress names: queued/processing vs pending/running.
  TERMINAL = {"completed", "failed", "canceled"}


  def wait_for_job(job_id, interval=5):
      """Generation and export jobs. The finished job carries output.url."""
      job = None

      while job is None or job["status"] not in TERMINAL:
          time.sleep(interval)

          response = requests.get(f"{API}/jobs/{job_id}", headers=HEADERS)
          response.raise_for_status()

          job = response.json()["data"]
          print("Job status:", job["status"])

      if job["status"] != "completed":
          raise RuntimeError(f"Job {job['status']}: {job.get('error', '')}")

      return job


  def wait_for_agent_job(job_id, interval=5, on_pause=None):
      """Agent jobs. The finished job carries project_id, thread_id and
      last_message, but no output: export the project to get a file.

      on_pause runs when an interactive run stops to ask something.
      """
      job = None

      while job is None or job["status"] not in TERMINAL:
          # This endpoint long-polls. Sleep anyway, so a fast response
          # cannot turn this into a tight request loop.
          time.sleep(interval)

          response = requests.get(f"{API}/agent/jobs/{job_id}", headers=HEADERS)
          response.raise_for_status()

          job = response.json()["data"]
          print("Edit status:", job["status"])

          # Only interactive runs reach this; unattended runs never pause.
          if job["status"] == "waiting_input" and on_pause:
              on_pause(job)

      if job["status"] != "completed":
          raise RuntimeError("The edit did not finish: " + (job.get("error") or job.get("reason", "")))

      return job
const PROJECT_ID = "3f0d5a4e-3d2a-4c1f-9b3e-2a1c4d5e6f70";

const exportResponse = await fetch(`${API}/projects/${PROJECT_ID}/export`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    settings: { target_resolution: "1080p", codec: "h264" },
  }),
});

if (!exportResponse.ok) {
  throw new Error("Could not start the export: " + exportResponse.status);
}

const exportBody = await exportResponse.json();

// Poll the export job until it finishes.
const job = await waitForJob(exportBody.data.job_id);

// Download the rendered video.
const videoResponse = await fetch(job.output.url);
const blob = await videoResponse.blob();

console.log("Downloaded " + blob.size + " bytes, type: " + blob.type);
PROJECT_ID = "3f0d5a4e-3d2a-4c1f-9b3e-2a1c4d5e6f70"

export_response = requests.post(
    f"{API}/projects/{PROJECT_ID}/export",
    headers=HEADERS,
    json={"settings": {"target_resolution": "1080p", "codec": "h264"}},
)
export_response.raise_for_status()

# Poll the export job until it finishes.
job = wait_for_job(export_response.json()["data"]["job_id"])

# Download the rendered video.
video = requests.get(job["output"]["url"])
video.raise_for_status()

with open("output.mp4", "wb") as f:
    f.write(video.content)

print(f"Saved {len(video.content)} bytes")

The download URL expires. output.url_expires_at says when. Poll the job again for a fresh one rather than storing it.

Handing off to the editor

Skip the export and open the project in the browser instead. Pass project_id to the SDK, or link the user straight to the Rendley editor:

https://app.rendley.com/editor/{project_id}

A human can then review the edit, tweak the timeline, and export manually.

Full automated editing flow

A typical integration chains three steps:

  1. Start an edit: send a prompt and media.
  2. Continue the edit: refine with follow-up instructions.
  3. Export the result (this page): render to MP4 and download.

For human-in-the-loop workflows, add approval gates between steps 1 and 3.