AutomationFlowsData & Sheets › Bulk Personalized Videos From CSV (zvid)

Bulk Personalized Videos From CSV (zvid)

Bulk personalized videos from CSV (Zvid). Uses httpRequest, readWriteFile. Event-driven trigger; 31 nodes.

Event trigger★★★★★ complexity31 nodesHTTP RequestRead Write File
Data & Sheets Trigger: Event Nodes: 31 Complexity: ★★★★★ Added:

This workflow follows the HTTP Request → Readwritefile recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "ZvidBulkPersonal",
  "name": "Bulk personalized videos from CSV (Zvid)",
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "note-overview",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -400
      ],
      "parameters": {
        "width": 620,
        "height": 340,
        "content": "## Bulk personalized videos from a CSV\nn8n port of [Zvid-io/bulk-personalized-videos](https://github.com/Zvid-io/bulk-personalized-videos): one designed template + one CSV row per customer = one branded 1080x1920 video each, via `POST /api/render/bulk/api-key`.\n\n**Setup**\n1. Create an API key at app.zvid.io -> API Keys.\n2. Create a **Header Auth** credential: name `x-api-key`, value = your key. Attach it to *Validate row (free)*, *Submit bulk render* and *Get batch status*.\n3. Tweak **Campaign Config**: point `csvUrl` / `templateUrl` / `campaignUrl` at your own files, set `rowLimit` (0 = all rows), or `dryRun: true` for a free per-row validation + credit estimate.\n\nAlso available: a Google Sheets variant and an event-driven webhook variant - see the `n8n/` folder of the repo. Prefer native nodes? The official [`@zvid/n8n-nodes-zvid`](https://www.npmjs.com/package/@zvid/n8n-nodes-zvid) package adds a Zvid node (bulk render, free validate, wait-for-completion) and an HMAC-verified Zvid Trigger."
      }
    },
    {
      "id": "note-poll",
      "name": "Sticky Note - Polling",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1420,
        520
      ],
      "parameters": {
        "width": 760,
        "height": 240,
        "color": 4,
        "content": "### Batch + submit + poll\nRows are submitted in batches of `batchSize` (the API accepts up to 500 items per request; plan limits may be lower). Each batch returns a `bulkId`; the loop's done-output collects every 202 response, then we poll `GET /api/render/bulk/:id` for **all** batches every `pollSeconds` until no job is pending, with a `timeoutMinutes` guard. API calls retry 3x on transient failures. Credits for failed jobs are refunded automatically.\n\nJob `name` is **null until completed** - results are mapped back to CSV rows by `jobId`, never by name."
      }
    },
    {
      "id": "note-output",
      "name": "Sticky Note - Output",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2900,
        -80
      ],
      "parameters": {
        "width": 660,
        "height": 250,
        "color": 5,
        "content": "### Outputs\n*Summary* ends the run with every video URL, credits reserved and problem rows - videos live on Zvid's CDN, so nothing needs to touch disk. That keeps the workflow n8n-Cloud-safe by default.\n\nSelf-hosting and want local files? Set `saveToDisk: true` to also write `results.csv` + the MP4s into `outDir`. n8n only allows writes under `~/.n8n-files` (`N8N_RESTRICT_FILE_ACCESS_TO`); create the folder once: `docker exec n8n mkdir -p /home/node/.n8n-files/zvid-out`, fetch with `docker cp n8n:/home/node/.n8n-files/zvid-out .`\n\nTo ship results elsewhere, replace the *Save* nodes with Google Drive / S3 / email nodes."
      }
    },
    {
      "id": "n-manual",
      "name": "When clicking 'Execute workflow'",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -180,
        96
      ],
      "parameters": {}
    },
    {
      "id": "n-config",
      "name": "Campaign Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        20,
        96
      ],
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"apiUrl\": \"https://api.zvid.io\",\n  \"templateUrl\": \"https://raw.githubusercontent.com/Zvid-io/bulk-personalized-videos/master/template.json\",\n  \"campaignUrl\": \"https://raw.githubusercontent.com/Zvid-io/bulk-personalized-videos/master/campaign.json\",\n  \"csvUrl\": \"https://raw.githubusercontent.com/Zvid-io/bulk-personalized-videos/master/data/customers.csv\",\n  \"batchName\": \"personalized-offers (n8n)\",\n  \"rowLimit\": 0,\n  \"dryRun\": false,\n  \"batchSize\": 100,\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 30,\n  \"saveToDisk\": false,\n  \"outDir\": \"/home/node/.n8n-files/zvid-out\"\n}",
        "options": {}
      }
    },
    {
      "id": "n-fetch-template",
      "name": "Fetch template.json",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        96
      ],
      "parameters": {
        "url": "={{ $json.templateUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      }
    },
    {
      "id": "n-fetch-campaign",
      "name": "Fetch campaign.json",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        420,
        96
      ],
      "parameters": {
        "url": "={{ $('Campaign Config').first().json.campaignUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      }
    },
    {
      "id": "n-fetch-csv",
      "name": "Fetch customers.csv",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        620,
        96
      ],
      "parameters": {
        "url": "={{ $('Campaign Config').first().json.csvUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      }
    },
    {
      "id": "n-parse-csv",
      "name": "Parse CSV rows",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        820,
        96
      ],
      "parameters": {
        "operation": "csv",
        "options": {}
      }
    },
    {
      "id": "n-build-items",
      "name": "Build bulk items",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1020,
        96
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Mirrors src/csv.js of the CLI example: required-column checks,\n// per-row validation, slugified job names, optional row limit.\nconst REQUIRED = ['first_name','product_name','product_tagline','product_image','discount_label','coupon_code','cta_url'];\nconst cfg = $('Campaign Config').first().json;\nconst rows = $input.all().map((i) => i.json);\nif (rows.length === 0) throw new Error('CSV contains no data rows');\n\nconst missing = REQUIRED.filter((c) => !(c in rows[0]));\nif (missing.length) {\n  throw new Error(`CSV is missing required column(s): ${missing.join(', ')}. Expected header: ${REQUIRED.join(',')}`);\n}\n\nconst slugify = (v) => String(v).toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'row';\n// note: the Code sandbox has no URL global - keep this a regex\nconst isHttpUrl = (v) => /^https?:\\/\\//i.test(String(v).trim());\n\nconst items = [];\nconst rowErrors = [];\nrows.forEach((record, index) => {\n  const row = index + 1; // 1-based CSV data row (header excluded)\n  const problems = [];\n  for (const c of REQUIRED) if (!record[c]) problems.push(`\"${c}\" is empty`);\n  if (record.product_image && !isHttpUrl(record.product_image)) problems.push('\"product_image\" must be an http(s) URL');\n  if (problems.length) { rowErrors.push({ row, error: problems.join('; ') }); return; }\n  items.push({\n    row,\n    name: `offer-${slugify(record.first_name)}-r${row}`,\n    variables: {\n      firstName: record.first_name,\n      productName: record.product_name,\n      productMeta: record.product_tagline,\n      productImage: record.product_image,\n      discountLabel: record.discount_label,\n      couponCode: record.coupon_code,\n      ctaUrl: record.cta_url,\n    },\n  });\n});\n\nif (items.length === 0) throw new Error('No valid rows in the CSV - nothing to render. Reasons: ' + JSON.stringify(rowErrors.slice(0, 5)));\nconst limit = Number(cfg.rowLimit) || 0;\nconst limited = limit > 0 ? items.slice(0, limit) : items;\n// rowErrors ride on the first item so the manifest can report skipped rows\nreturn limited.map((it, i) => ({ json: i === 0 ? { ...it, rowErrors } : it }));"
      }
    },
    {
      "id": "n-if-dryrun",
      "name": "Dry run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1220,
        96
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-dryrun",
              "leftValue": "={{ $('Campaign Config').first().json.dryRun }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-validate",
      "name": "Validate row (free)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1460,
        -64
      ],
      "onError": "continueRegularOutput",
      "parameters": {
        "method": "POST",
        "url": "={{ $('Campaign Config').first().json.apiUrl }}/api/render/validate/api-key",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ payload: $('Fetch template.json').first().json, variables: Object.assign({}, $('Fetch campaign.json').first().json, $json.variables) }) }}",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "n-estimate",
      "name": "Credit estimate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1660,
        -64
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Per-row validation report + total credit estimate. No credits spent.\nconst items = $('Build bulk items').all().map((i) => i.json);\nconst results = $input.all().map((i) => i.json);\nlet creditsRequired = 0;\nlet invalid = 0;\nconst rows = results.map((r, i) => {\n  const item = items[i] || {};\n  if (r.error || r.valid === false) {\n    invalid += 1;\n    const message = typeof r.error === 'string' ? r.error : (r.error && r.error.message) || r.message || 'invalid';\n    return { csv_row: item.row, name: item.name, valid: false, error: message };\n  }\n  creditsRequired += r.creditsRequired || 0;\n  return { csv_row: item.row, name: item.name, valid: true, credits: r.creditsRequired, layoutWarnings: (r.warnings || []).length };\n});\nreturn [{ json: {\n  dryRun: true,\n  videos: results.length - invalid,\n  invalidRows: invalid,\n  creditsRequired,\n  note: 'No credits were spent. Set dryRun=false in Campaign Config to render.',\n  rows,\n} }];"
      }
    },
    {
      "id": "n-split-batches",
      "name": "Split into batches",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1460,
        256
      ],
      "parameters": {
        "batchSize": "={{ $('Campaign Config').first().json.batchSize }}",
        "options": {}
      }
    },
    {
      "id": "n-build-request",
      "name": "Build bulk request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1660,
        400
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// One envelope per batch (up to batchSize items) - same merge semantics as\n// the CLI: item variables > batch variables > template defaults.\nconst cfg = $('Campaign Config').first().json;\nconst items = $input.all().map((i) => i.json);\nreturn [{ json: {\n  request: {\n    payload: $('Fetch template.json').first().json,\n    variables: $('Fetch campaign.json').first().json,\n    items: items.map(({ variables, name }) => ({ variables, name })),\n    name: cfg.batchName,\n  },\n} }];"
      }
    },
    {
      "id": "n-submit",
      "name": "Submit bulk render",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1860,
        400
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "parameters": {
        "method": "POST",
        "url": "={{ $('Campaign Config').first().json.apiUrl }}/api/render/bulk/api-key",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.request) }}",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "n-collect",
      "name": "Collect submissions",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1700,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// The loop's done-output delivers one 202 response per batch, in batch order.\n// Re-derive each batch's rows by slicing the same item list the loop consumed,\n// then map every queued job to its CSV row via the batch-relative jobs[].index.\nconst cfg = $('Campaign Config').first().json;\nconst batchSize = Math.max(1, Number(cfg.batchSize) || 100);\nconst allItems = $('Build bulk items').all().map((i) => i.json);\nconst submits = $input.all().map((i) => i.json);\n\nconst jobsMeta = [];\nconst rejected = [];\nconst bulks = [];\nsubmits.forEach((submit, b) => {\n  const batch = allItems.slice(b * batchSize, (b + 1) * batchSize);\n  for (const job of submit.jobs || []) {\n    const item = batch[job.index] || {};\n    jobsMeta.push({ jobId: job.jobId, row: item.row, name: item.name });\n  }\n  for (const e of submit.errors || submit.itemErrors || []) {\n    const idx = e.item != null ? e.item : e.index;\n    const item = typeof idx === 'number' ? batch[idx] : undefined;\n    const details = (e.errors || e.details || []).map((d) => `${d.field}: ${d.message}`).join('; ');\n    rejected.push({ csv_row: item ? item.row : null, name: item ? item.name : `item-${idx}`, error: details || e.error || e.message || 'invalid' });\n  }\n  bulks.push({ bulkId: submit.bulkId, creditsReserved: submit.creditsReserved || 0, totalJobs: submit.totalJobs || 0 });\n});\n\nconst aggregate = {\n  jobsMeta,\n  rejected,\n  rowErrors: $('Build bulk items').first().json.rowErrors || [],\n  creditsReservedTotal: bulks.reduce((a, x) => a + x.creditsReserved, 0),\n  totalJobs: bulks.reduce((a, x) => a + x.totalJobs, 0),\n  bulkIds: bulks.map((x) => x.bulkId),\n};\n// One output item per bulkId (the poll loop fetches each); aggregate rides on the first.\nreturn bulks.map((x, i) => ({ json: i === 0 ? { ...x, aggregate } : x }));"
      }
    },
    {
      "id": "n-wait",
      "name": "Wait before poll",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1900,
        192
      ],
      "parameters": {
        "amount": "={{ $('Campaign Config').first().json.pollSeconds }}"
      }
    },
    {
      "id": "n-get-status",
      "name": "Get batch status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2100,
        192
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "parameters": {
        "url": "={{ $('Campaign Config').first().json.apiUrl }}/api/render/bulk/{{ $json.bulkId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "n-check-done",
      "name": "Check all done",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Aggregate progress across every batch. Input order always mirrors Collect\n// submissions' output order, so bulkIds can be re-attached by position.\nconst bulkIds = $('Collect submissions').all().map((i) => i.json.bulkId);\nconst responses = $input.all().map((i) => i.json);\nconst counts = { completed: 0, failed: 0, pending: 0 };\nfor (const r of responses) {\n  counts.completed += r.bulk.counts.completed;\n  counts.failed += r.bulk.counts.failed;\n  counts.pending += r.bulk.counts.pending;\n}\nconst done = counts.pending === 0;\nreturn responses.map((r, i) => ({ json: { bulkId: bulkIds[i], done, counts, response: r } }));"
      }
    },
    {
      "id": "n-if-done",
      "name": "All jobs finished?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2500,
        192
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-done",
              "leftValue": "={{ $json.done }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-timeout",
      "name": "Poll timeout guard",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        384
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Loop back to Wait unless we have polled for longer than timeoutMinutes.\nconst cfg = $('Campaign Config').first().json;\nconst maxPolls = Math.ceil((Number(cfg.timeoutMinutes || 30) * 60) / Math.max(2, Number(cfg.pollSeconds || 10)));\nif ($runIndex >= maxPolls) {\n  const first = $input.first().json;\n  throw new Error(`Timed out after ${cfg.timeoutMinutes} min with ${first.counts.pending} job(s) still rendering. Check later: GET ${cfg.apiUrl}/api/render/bulk/${first.bulkId}`);\n}\nreturn $input.all();"
      }
    },
    {
      "id": "n-manifest",
      "name": "Build manifest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2720,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Join poll results to CSV rows BY JOB ID (job name is null until completed),\n// plus items the API rejected at submit and rows skipped locally.\nconst meta = $('Collect submissions').first().json.aggregate;\nconst byJobId = new Map(meta.jobsMeta.map((m) => [m.jobId, m]));\nconst rows = [];\nfor (const item of $input.all()) {\n  for (const job of item.json.response.jobs || []) {\n    const m = byJobId.get(job.id) || {};\n    rows.push({\n      csv_row: m.row != null ? m.row : null,\n      name: job.name || m.name || job.id,\n      status: job.status,\n      video_url: job.outputUrl || '',\n      thumbnail_url: job.thumbnailUrl || '',\n      credits: job.creditsConsumed != null ? job.creditsConsumed : null,\n      error: job.errorMessage || '',\n    });\n  }\n}\nfor (const e of meta.rejected || []) {\n  rows.push({ csv_row: e.csv_row, name: e.name, status: 'rejected', video_url: '', thumbnail_url: '', credits: null, error: e.error });\n}\nfor (const e of meta.rowErrors || []) {\n  rows.push({ csv_row: e.row, name: '', status: 'skipped', video_url: '', thumbnail_url: '', credits: null, error: e.error });\n}\nrows.sort((a, b) => ((a.csv_row != null ? a.csv_row : 1e9) - (b.csv_row != null ? b.csv_row : 1e9)));\nreturn rows.map((r) => ({ json: r }));"
      }
    },
    {
      "id": "n-summary",
      "name": "Summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2940,
        64
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const manifest = $('Build manifest').all().map((i) => i.json);\nconst meta = $('Collect submissions').first().json.aggregate;\nconst cfg = $('Campaign Config').first().json;\nconst rendered = manifest.filter((r) => r.status === 'completed');\nconst problems = manifest.filter((r) => r.status !== 'completed');\nreturn [{ json: {\n  rendered: rendered.length,\n  failedOrSkipped: problems.length,\n  creditsReserved: meta.creditsReservedTotal,\n  bulkIds: meta.bulkIds,\n  savedTo: cfg.saveToDisk ? cfg.outDir : '(saveToDisk=false - videos stay on the CDN, URLs below)',\n  videos: rendered.map((r) => ({ name: r.name, url: r.video_url })),\n  problems,\n} }];"
      }
    },
    {
      "id": "n-if-save",
      "name": "Save to disk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2940,
        288
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-save",
              "leftValue": "={{ $('Campaign Config').first().json.saveToDisk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-tocsv",
      "name": "Manifest to CSV",
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [
        3160,
        192
      ],
      "parameters": {
        "operation": "csv",
        "options": {
          "fileName": "results.csv"
        }
      }
    },
    {
      "id": "n-save-csv",
      "name": "Save results.csv",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        3360,
        192
      ],
      "parameters": {
        "operation": "write",
        "fileName": "={{ $('Campaign Config').first().json.outDir }}/results.csv",
        "dataPropertyName": "data",
        "options": {}
      }
    },
    {
      "id": "n-filter-completed",
      "name": "Filter completed videos",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        3160,
        384
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c-completed",
              "leftValue": "={{ $json.status }}",
              "rightValue": "completed",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "n-download",
      "name": "Download video",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3360,
        384
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "parameters": {
        "url": "={{ $json.video_url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      }
    },
    {
      "id": "n-save-mp4",
      "name": "Save MP4",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        3560,
        384
      ],
      "parameters": {
        "operation": "write",
        "fileName": "={{ $('Campaign Config').first().json.outDir }}/{{ $('Filter completed videos').item.json.name }}.mp4",
        "dataPropertyName": "data",
        "options": {}
      }
    },
    {
      "id": "note-package",
      "name": "Sticky Note - Official nodes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        480
      ],
      "parameters": {
        "width": 640,
        "height": 430,
        "color": 6,
        "content": "### Optional: official Zvid nodes (`@zvid/n8n-nodes-zvid`)\nThis workflow runs on n8n **core nodes only** - nothing extra to install. If you self-host n8n, you can install Zvid's official community package and swap the raw HTTP pieces for native nodes:\n\n**Install** (self-hosted n8n)\n1. **Settings \u2192 Community Nodes \u2192 Install** and enter `@zvid/n8n-nodes-zvid`  \n   (manual alternative: `cd ~/.n8n/nodes && npm install @zvid/n8n-nodes-zvid`, then restart n8n).\n2. Create its **Zvid API** credential with the same API key (`zvid_...` from app.zvid.io \u2192 API Keys).\n\n**What it replaces here**\n- *Zvid node \u2192 Render \u2192 Create Bulk* (+ \"Wait for Completion\") = *Build bulk request* + *Submit bulk render* + the whole poll loop\n- *Zvid node \u2192 Render \u2192 Validate* (free) = the *Validate row (free)* dry-run branch\n- *Zvid Trigger* = signed `render.completed` / `render.failed` events instead of polling\n\nn8n **Cloud** can only install verified community nodes - until `@zvid/n8n-nodes-zvid` is verified there, use this workflow as-is (that's exactly why it sticks to core nodes)."
      }
    }
  ],
  "connections": {
    "When clicking 'Execute workflow'": {
      "main": [
        [
          {
            "node": "Campaign Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Campaign Config": {
      "main": [
        [
          {
            "node": "Fetch template.json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch template.json": {
      "main": [
        [
          {
            "node": "Fetch campaign.json",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch campaign.json": {
      "main": [
        [
          {
            "node": "Fetch customers.csv",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch customers.csv": {
      "main": [
        [
          {
            "node": "Parse CSV rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse CSV rows": {
      "main": [
        [
          {
            "node": "Build bulk items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build bulk items": {
      "main": [
        [
          {
            "node": "Dry run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry run?": {
      "main": [
        [
          {
            "node": "Validate row (free)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Split into batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate row (free)": {
      "main": [
        [
          {
            "node": "Credit estimate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split into batches": {
      "main": [
        [
          {
            "node": "Collect submissions",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build bulk request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build bulk request": {
      "main": [
        [
          {
            "node": "Submit bulk render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit bulk render": {
      "main": [
        [
          {
            "node": "Split into batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect submissions": {
      "main": [
        [
          {
            "node": "Wait before poll",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait before poll": {
      "main": [
        [
          {
            "node": "Get batch status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get batch status": {
      "main": [
        [
          {
            "node": "Check all done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check all done": {
      "main": [
        [
          {
            "node": "All jobs finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "All jobs finished?": {
      "main": [
        [
          {
            "node": "Build manifest",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Poll timeout guard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll timeout guard": {
      "main": [
        [
          {
            "node": "Wait before poll",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build manifest": {
      "main": [
        [
          {
            "node": "Summary",
            "type": "main",
            "index": 0
          },
          {
            "node": "Save to disk?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save to disk?": {
      "main": [
        [
          {
            "node": "Manifest to CSV",
            "type": "main",
            "index": 0
          },
          {
            "node": "Filter completed videos",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Manifest to CSV": {
      "main": [
        [
          {
            "node": "Save results.csv",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter completed videos": {
      "main": [
        [
          {
            "node": "Download video",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download video": {
      "main": [
        [
          {
            "node": "Save MP4",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Bulk personalized videos from CSV (Zvid). Uses httpRequest, readWriteFile. Event-driven trigger; 31 nodes.

Source: https://github.com/Zvid-io/bulk-personalized-videos/blob/master/n8n/zvid-bulk-personalized-videos.workflow.json — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

Email Verifier from CSV. Uses httpRequest, emailSend, readWriteFile. Event-driven trigger; 10 nodes.

HTTP Request, Email Send, Read Write File
Data & Sheets

Workflow 01.01. Uses notion, executeWorkflowTrigger, httpRequest. Event-driven trigger; 60 nodes.

Notion, Execute Workflow Trigger, HTTP Request
Data & Sheets

Lmchatopenai Workflow. Uses noOp, stickyNote, executeWorkflowTrigger, airtable. Event-driven trigger; 41 nodes.

Execute Workflow Trigger, Airtable, HTTP Request
Data & Sheets

This n8n workflow retrieves an Airtable record along with its related child records in a hierarchical structure. It can fetch up to 3 levels of linked records and assembles them into a comprehensive J

Execute Workflow Trigger, Airtable, HTTP Request
Data & Sheets

Automate sales call analysis and store structured insights in Notion with AI-powered intelligence.

Execute Workflow Trigger, Notion, HTTP Request