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.

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.
| vin | make_model | year_mileage | price | vehicle_image_1 |
|---|---|---|---|---|
| 1FA6P8JZ5N5 | Ford Mustang Shelby GT350 | 2023 · 12,480 mi | $64,900 | https://cdn.example.com/gt350-1.jpg |
| WBS8M9C57J5 | BMW M3 Competition | 2024 · 6,120 mi | $79,500 | https://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.
| Rows | Credits | Plan | Cost |
|---|---|---|---|
| 100 | 50 | Creator, $29/mo | included |
| 1,000 | 500 | Creator + 200 extra | $29 + $20 |
| 5,000 | 2,500 | Business + 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.
Related guides
Google Sheets to Video: Automate Video Creation from a Sheet
Turn spreadsheet rows into finished videos. Connect a Google Sheet to the Renderly API with Apps Script, map columns to variables, and render every row.
Airtable to Video: Automate Video Creation from a Base
Turn Airtable records into finished videos automatically. Connect a base to the Renderly API, map fields to template variables, and render every row without opening an editor.