Rendley docs

Guides

Generate videos in bulk

One video per row, each with its own name, product, or message. This script keeps a bounded number of agent jobs in flight and downloads each finished MP4.

Use it for personalized outreach at scale, batch product videos, or templated social content.

The approach

There is no batch endpoint. Run several independent agent jobs concurrently, but keep the number below your plan limit. As one job finishes, start the next row.

POST /v1/agent              ← start up to your concurrency limit
GET  /v1/agent/jobs/{id}    ← poll each active edit
POST /v1/export             ← export a completed project
GET  /v1/jobs/{id}          ← poll the export and download the MP4

The script

This generates a personalized product demo for each customer in a list. Set MAX_CONCURRENT below to a value allowed by your plan.

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
import { writeFile } from "node:fs/promises";

// Each entry becomes a separate video.
const CUSTOMERS = [
  { name: "Acme Corp", product: "Analytics Dashboard", cta: "Start your free trial" },
  { name: "Globex Inc", product: "Workflow Automation", cta: "Book a demo today" },
  { name: "Initech", product: "Team Collaboration", cta: "See it in action" },
];


// One prompt per row, built from a fixed template.
function buildPrompt(customer) {
  return [
    "Create a 30-second product demo video.",
    "Open with the text '" + customer.name + "' as a title card.",
    "Show the product name '" + customer.product + "' with a sleek animation.",
    "End with a call-to-action: '" + customer.cta + "'.",
    "Use professional background music.",
  ].join(" ");
}


// Start one agent run. Returns before any editing happens.
async function startJob(customer) {
  const response = await fetch(`${API}/agent`, {
    method: "POST",
    headers,
    body: JSON.stringify({ prompt: buildPrompt(customer) }),
  });

  if (!response.ok) {
    throw new Error("Could not start a job: " + response.status);
  }

  const body = await response.json();
  const data = body.data;

  return { ...customer, job_id: data.job_id, project_id: data.project_id };
}


// Render one finished project and save the MP4 beside this script.
async function exportAndDownload(name, job) {
  const response = await fetch(`${API}/export`, {
    method: "POST",
    headers,
    body: JSON.stringify({ project_id: job.project_id }),
  });

  const body = await response.json();

  let exportJob;
  try {
    exportJob = await waitForJob(body.data.job_id);
  } catch (error) {
    console.log("  Export failed for " + name + ": " + error.message);
    return;
  }

  // The finished job carries a signed URL. Download it before it expires.
  const file = await fetch(exportJob.output.url);

  if (!file.ok) {
    console.log("  Download failed for " + name + ": " + file.status);
    return;
  }

  const filename = name.toLowerCase().replace(/ /g, "-") + ".mp4";
  const bytes = Buffer.from(await file.arrayBuffer());

  await writeFile(filename, bytes);
  console.log("  Saved " + filename);
}


// Each worker owns one row from start through download. That keeps the
// number of active agent edits bounded for the whole run.
const MAX_CONCURRENT = 1; // Raise this only when your plan allows more active edits.
let nextIndex = 0;


async function worker() {
  while (nextIndex < CUSTOMERS.length) {
    const customer = CUSTOMERS[nextIndex++];

    try {
      const started = await startJob(customer);
      const finished = await waitForAgentJob(started.job_id);
      await exportAndDownload(customer.name, finished);
    } catch (error) {
      console.log("  Failed " + customer.name + ": " + error.message);
    }
  }
}


console.log("Processing " + CUSTOMERS.length + " videos...");

const workers = Array.from(
  { length: Math.min(MAX_CONCURRENT, CUSTOMERS.length) },
  () => worker(),
);

await Promise.all(workers);
console.log("Done!");
import concurrent.futures
import requests

# Each entry becomes a separate video.
CUSTOMERS = [
    {"name": "Acme Corp", "product": "Analytics Dashboard", "cta": "Start your free trial"},
    {"name": "Globex Inc", "product": "Workflow Automation", "cta": "Book a demo today"},
    {"name": "Initech", "product": "Team Collaboration", "cta": "See it in action"},
]

TEMPLATE_PROMPT = (
    "Create a 30-second product demo video. "
    "Open with the text '{name}' as a title card. "
    "Show the product name '{product}' with a sleek animation. "
    "End with a call-to-action: '{cta}'. "
    "Use professional background music."
)


def start_job(customer):
    """Start one agent run. Returns before any editing happens."""
    prompt = TEMPLATE_PROMPT.format(**customer)

    response = requests.post(
        f"{API}/agent",
        headers=HEADERS,
        json={"prompt": prompt},
    )
    response.raise_for_status()

    data = response.json()["data"]

    return {**customer, "job_id": data["job_id"], "project_id": data["project_id"]}


def export_and_download(name, job):
    """Render one finished project and save the MP4 beside this script."""
    response = requests.post(
        f"{API}/export",
        headers=HEADERS,
        json={"project_id": job["project_id"]},
    )
    response.raise_for_status()

    export_job_id = response.json()["data"]["job_id"]

    try:
        export_job = wait_for_job(export_job_id)
    except Exception as error:
        print(f"  Export failed for {name}: {error}")
        return

    # The finished job carries a signed URL. Stream it straight to disk.
    filename = f"{name.lower().replace(' ', '-')}.mp4"

    with requests.get(export_job["output"]["url"], stream=True) as download:
        download.raise_for_status()

        with open(filename, "wb") as f:
            for chunk in download.iter_content(1 << 16):
                f.write(chunk)

    print(f"  Saved {filename}")


# Each worker owns one row from start through download. That keeps the
# number of active agent edits bounded for the whole run.
MAX_CONCURRENT = 1  # Raise this only when your plan allows more active edits.


def process_customer(customer):
    try:
        started = start_job(customer)
        finished = wait_for_agent_job(started["job_id"])
        export_and_download(customer["name"], finished)
    except Exception as error:
        print(f"  Failed {customer['name']}: {error}")


print(f"Processing {len(CUSTOMERS)} videos...")

with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENT) as pool:
    futures = [pool.submit(process_customer, customer) for customer in CUSTOMERS]
    concurrent.futures.wait(futures)

print("Done!")

Scaling tips

  • Concurrency. Jobs run in parallel, but each account has a cap that depends on its plan. Going over it returns 429 with AGENT_CONCURRENCY_LIMIT_REACHED. Keep a fixed number in flight and start the next job as one finishes, rather than firing every row at once.
  • Reuse media. Footage that appears in several videos belongs in the library. Upload it once, then pass { "media_id": "..." } in files on each job instead of the same URL every time. An entry needs a url, a storage_url or a media_id.
  • Templates. Structure your prompts around variables (name, product, CTA) so every video follows a consistent layout.
  • Error handling. Jobs are independent, so check each one’s status before exporting it rather than assuming the batch succeeded as a whole.