AutomationFlowsWeb Scraping › Bulk Personalized Videos (zvid) - Webhook Submit

Bulk Personalized Videos (zvid) - Webhook Submit

Bulk personalized videos (Zvid) - webhook submit. Uses httpRequest. Event-driven trigger; 16 nodes.

Event trigger★★★★☆ complexity16 nodesHTTP Request
Web Scraping Trigger: Event Nodes: 16 Complexity: ★★★★☆ Added:

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": "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.

Pro

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 →

More Web Scraping workflows → · Browse all categories →

Related workflows

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

Web Scraping

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

Trello Trigger, HTTP Request
Web Scraping

02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.

Execute Workflow Trigger, HTTP Request, Sea Table
Web Scraping

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

Execute Command, Read Write File, HTTP Request +3
Web Scraping

[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.

HTTP Request, GitHub, Stop And Error +1
Web Scraping

[](https://youtu.be/c7yCZhmMjtI)

HTTP Request, GitHub, Stop And Error +1