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 →
{
"id": "ZvidBulkWhSubmit",
"name": "Bulk personalized videos (Zvid) - webhook submit",
"active": false,
"settings": {
"executionOrder": "v1"
},
"nodes": [
{
"id": "note-overview",
"name": "Sticky Note - Overview",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-260,
-420
],
"parameters": {
"width": 660,
"height": 360,
"content": "## Webhook variant - submit side (no polling)\nSubmits the whole CSV as bulk render batches and **ends immediately**. Zvid then POSTs a `render.completed` / `render.failed` event to your receiver workflow for every job - no poll loop, no long-running execution, scales to 500-item batches.\n\n**Setup**\n1. Import + **activate** the companion *Zvid render events receiver* workflow and copy its production webhook URL.\n2. Put that URL in `webhookReceiverUrl` in **Campaign Config**. It must be **publicly reachable** - Zvid refuses private/localhost URLs in production (a tunnel like ngrok/cloudflared works for local n8n).\n3. Same credential + config knobs as the polling workflow (`dryRun: true` = free validation + credit estimate).\n\nPrefer the polling workflow when you want one execution that ends with the complete manifest."
}
},
{
"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 webhook)\",\n \"webhookReceiverUrl\": \"https://YOUR-N8N-HOST/webhook/zvid-render-events\",\n \"rowLimit\": 0,\n \"dryRun\": false,\n \"batchSize\": 100\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.\n// The -r<row> suffix in the job name is what lets the receiver workflow map\n// each webhook event back to its CSV row.\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 summary 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. webhookUrl makes Zvid POST a render.completed /\n// render.failed event to the receiver workflow for every job in the batch.\nconst cfg = $('Campaign Config').first().json;\nconst receiver = String(cfg.webhookReceiverUrl || '');\nif (!/^https?:\\/\\//i.test(receiver) || receiver.includes('YOUR-N8N-HOST')) {\n throw new Error('Set webhookReceiverUrl in Campaign Config to your receiver workflow\\'s production webhook URL (publicly reachable).');\n}\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 webhookUrl: receiver,\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-summary",
"name": "Submission summary",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1700,
192
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// One 202 response per batch arrives on the loop's done-output, in order.\n// Map queued jobs back to CSV rows 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 queued = [];\nconst rejected = [];\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 queued.push({ jobId: job.jobId, csv_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});\n\nreturn [{ json: {\n submitted: queued.length,\n rejected,\n rowErrors: $('Build bulk items').first().json.rowErrors || [],\n creditsReserved: submits.reduce((a, s) => a + (s.creditsReserved || 0), 0),\n bulkIds: submits.map((s) => s.bulkId),\n receiver: cfg.webhookReceiverUrl,\n note: 'Done - no polling. Zvid will POST render.completed / render.failed to the receiver for every job. Credits for failed jobs are refunded automatically.',\n queued,\n} }];"
}
},
{
"id": "note-package",
"name": "Sticky Note - Official nodes",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-260,
300
],
"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. Self-hosting? Zvid's official community package gives you native replacements:\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* = *Build bulk request* + *Submit bulk render*\n- *Zvid Trigger* = a signed, auto-registered event receiver - replaces the companion *Zvid render events receiver* workflow and its treat-the-URL-as-a-secret caveats\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": "Submission summary",
"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
}
]
]
}
}
}
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.
httpHeaderAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Bulk personalized videos (Zvid) - webhook submit. Uses httpRequest. Event-driven trigger; 16 nodes.
Source: https://github.com/Zvid-io/bulk-personalized-videos/blob/master/n8n/zvid-bulk-webhook-submit.workflow.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow listens for an “Approved” label on a Trello card, reads the AI draft bookkeeping JSON from card comments, and posts the corresponding transaction to Xero. It then adds a Xero deep link b
02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.
This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t
[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.
[](https://youtu.be/c7yCZhmMjtI)