New: AI voiceover + auto-captions - script to captioned video in one API call. Read the announcement

How-to

Bulk Video from CSV: Generate One Video per Row

Turn a CSV export into finished videos. Map headers to template variables, send one render request per row from a resumable script, and collect every result.

Bulk Video from CSV: Generate One Video per Row

A CSV becomes video when each header maps to a template variable and each row becomes one request. A short script does the whole job: read the file, send one POST per row, record the job ID. Setup takes about 20 minutes, and the same script then handles a file of any size.

The finished video

This guide renders the Vehicle Listing Showcase — a 21-second 16:9 listing video built for a dealer inventory export. Thirteen fields change per row: three photos, make_model, year_mileage, three spec values, price, monthly_payment, dealer_name, dealer_phone and dealer_logo. Watch the output on the template page before you build anything.

Any template works. Only the column names change.

What you need

  • A CSV with one row per video you want.
  • A Renderly API key. Create one in the dashboard under Settings → API keys.
  • Node 18 or later, for the script in Step 4.

Step 1 - Prepare the CSV

Name every header after a template variable, then add one column the script can key on. In an inventory export that key already exists — the VIN, the SKU, the order number.

vinmake_modelyear_mileagepricevehicle_image_1
1FA6P8JZ5N5Ford Mustang Shelby GT3502023 · 12,480 mi$64,900https://cdn.example.com/gt350-1.jpg
WBS8M9C57J5BMW M3 Competition2024 · 6,120 mi$79,500https://cdn.example.com/m3-1.jpg

This is the one thing a CSV does differently from a spreadsheet or a database. A sheet holds a status column that the automation writes back to. A flat file has nowhere to put that, so the key column and a separate ledger take its place.

Two file details that break runs: any value containing a comma must be quoted, and every image URL must serve the file directly. Parse the file with a real CSV parser rather than splitting on commas, or the first quoted price will shift every column after it.

Step 2 - Get a Renderly API key and template ID

List the templates available to you and copy the id of the one you want:

curl https://renderly.video/api/v1/templates \
  -H "Authorization: Bearer $RENDERLY_API_KEY"

The template ID is not the URL slug. Renders resolve templateId against the template's id, so the Vehicle Listing Showcase — which lives at /templates/vehicle-listing-showcase — is car-listing-showcase in a request body. Copy the value from this response, not from the address bar.

Step 3 - Map CSV headers to template variables

Renderly templates mark replaceable elements with isDynamic: true, and the name of each dynamic overlay is the key you send in the replacements object. Match those names to your headers one to one. Text and shape overlays take content; image, video and sound overlays take src.

For the exact contract, including how much text fits in each slot before the renderer clips it, ask for one template's variables:

curl https://renderly.video/api/v1/templates/car-listing-showcase/variables \
  -H "Authorization: Bearer $RENDERLY_API_KEY"

That maxCharacters figure is worth reading before a large run. A file of 3,000 rows will contain longer values than the two rows you tested with.

Step 4 - Render every row with a resumable script

Write the ledger entry before moving to the next row. That ordering is what makes a crash safe: a row already recorded is skipped on restart, so no row renders twice and no row is silently lost.

import { readFileSync, appendFileSync, existsSync } from "node:fs";
import { parse } from "csv-parse/sync";
 
const KEY = "vin";                 // the stable id column
const LEDGER = "rendered.jsonl";
const SKIP = new Set([KEY]);       // headers that are not template variables
 
const rows = parse(readFileSync("inventory.csv"), { columns: true });
const done = new Set(
  existsSync(LEDGER)
    ? readFileSync(LEDGER, "utf8").trim().split("\n").filter(Boolean)
        .map((line) => JSON.parse(line).key)
    : [],
);
 
for (const row of rows) {
  if (done.has(row[KEY])) continue;
 
  const replacements = Object.fromEntries(
    Object.entries(row).filter(([k, v]) => !SKIP.has(k) && v !== ""),
  );
 
  const response = await fetch("https://renderly.video/api/v1/renders", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RENDERLY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      templateId: "car-listing-showcase",
      replacements,
      webhookUrl: "https://your-app.com/hooks/renderly",
    }),
  });
 
  if (response.status === 429) break;   // rate limited: stop, rerun later
  const { data } = await response.json();
 
  // Claim the row before reading the next one.
  appendFileSync(LEDGER, JSON.stringify({ key: row[KEY], jobId: data.jobId }) + "\n");
}

The request returns a job ID immediately, because rendering is asynchronous. Nothing in this loop waits for a video.

Stopping on a 429 rather than retrying is deliberate for a first run. The ledger already holds everything sent, so rerunning the script resumes exactly where the limit was hit.

Step 5 - Collect the finished videos with a webhook

Because webhookUrl was included, Renderly posts back when each job finishes:

{
  "event": "render.completed",
  "data": {
    "jobId": "cm9x2k4p10001qw8r7a3b2c1d",
    "status": "COMPLETED",
    "outputUrl": "https://cdn.renderly.video/renders/cm9x2k4p10001qw8r7a3b2c1d.mp4",
    "creditsUsed": 0.5,
    "durationInFrames": 630,
    "fps": 30
  },
  "timestamp": "2026-08-14T09:12:44.110Z"
}

Join jobId back to the ledger to recover which row produced which video, then write the pair out as a results CSV. The ledger is the only thing that connects the two, which is why it is written per row rather than at the end of the run.

Payloads are signed with HMAC-SHA256. Verify the signature before you trust the body — the webhooks guide covers the check.

What this costs at scale

Renderly bills 1 credit per minute of 1080p output, rounded up to the nearest half credit. The listing showcase runs 21 seconds, so each row costs half a credit.

RowsCreditsPlanCost
10050Creator, $29/moincluded
1,000500Creator + 200 extra$29 + $20
5,0002,500Business + 1,000 extra$99 + $80

Extra credits are $0.10 on Creator and $0.08 on Business, so a 5,000-video run costs about $179, or 3.6 cents per finished video. The rounding is the lever worth knowing: everything up to 30 seconds bills half a credit, so a 21-second cut and a 29-second cut cost the same.

Where to go next

Only Step 1 changes for a live source. The replacements object and the render call are identical, which is why the Google Sheets guide and the Airtable guide share Steps 2 through 5 with this one. Move to one of those when the file stops being a one-time export and starts being a list somebody edits.

For the architecture behind a continuous pipeline rather than a single file, read how to generate 1,000+ personalized videos with API automation.