Developer Tutorials
Product Video API: Personalized Offers with Node.js
Build personalized ecommerce offer videos with Node.js and Zvid. Map recipient data into one JSON design, validate costs, render a batch and resume downloads.
By Mina RamzyPublished Updated
A product video API lets your application turn approved product and customer data into finished videos. In this tutorial, one JSON design becomes two personalized ecommerce offers: a watch for Amira and sneakers for Omar. Node.js supplies the names, photos, discounts and coupon codes, then downloads a separate MP4 for each recipient.
You will run a complete example, inspect its variable mapping, validate the cost, submit a small batch and recover the same jobs if the process stops. The design stays in a file you can version alongside your code.
If you want to upload a spreadsheet without writing code, use Create product videos from a CSV without coding. This walkthrough is for developers adding personalized offers to an application or backend workflow.
The two offers you will create
Both videos use the same three scenes: a greeting, a product photo with an offer, and a coupon code. The layout and music stay fixed; the recipient and product values change.
ARCADIA, the recipients and the offers are fictional examples. Each vertical video is 1080 × 1920 at 30 frames per second and lasts about 10.25 seconds. The sample costs 11 render credits per video, 22 for the pair. Check the current estimate before rendering changed inputs.
1. Get the example and connect your account
You need Node.js 20 or newer, a terminal, a Zvid account and enough credits for the validated render estimate. Create an API key. Keep it in your local environment or server configuration; never put it in browser code or commit it to Git.
Extract the ZIP and open a terminal in its bulk-personalized-videos folder. Install the pinned dependencies and run the offline checks:
npm ci
npm test
Copy .env.example to a new file named .env using your file manager. Open .env and add your key:
ZVID_API_KEY=zvid_your_key_here
Replace the placeholder with your real key. Leave the optional API origin and webhook settings unset for this walkthrough. The script uses https://api.zvid.io and the official @zvid/sdk client. See API authentication for the header contract if you use another HTTP client.
2. Keep the design separate from each offer
Three files control the result:
| File | What changes here | Example |
|---|---|---|
template.json |
Scene layout, timing, typography and named placeholders | {{firstName}}, {{productImage}}, {{couponCode}} |
campaign.json |
Values shared by the campaign | ARCADIA, the headline, accent color and music |
data/customers.csv |
Values for one recipient's video | Amira, The Field Watch, 20% OFF, AMIRA-20 |
The CSV contains four sample recipients. The commands below select only its first two data rows, Amira and Omar. Keep the supplied files unchanged for the first run.
The mapping in src/csv.js connects each column to the exact variable name used by the design:
export function rowToVariables(row) {
return {
firstName: row.first_name,
productName: row.product_name,
productMeta: row.product_tagline,
productImage: row.product_image,
Collapse example
export function rowToVariables(row) {
return {
firstName: row.first_name,
productName: row.product_name,
productMeta: row.product_tagline,
productImage: row.product_image,
discountLabel: row.discount_label,
couponCode: row.coupon_code,
ctaUrl: row.cta_url,
};
}
For Amira, coupon_code contains AMIRA-20. The mapping produces couponCode: "AMIRA-20", and the final scene displays that value wherever the design uses {{couponCode}}. The script does not generate or activate a coupon in your store.
All seven columns are required by this example so a missing offer does not silently fall back to a sample value. That is the application's input policy. To support a different spreadsheet, change REQUIRED_COLUMNS and rowToVariables() together. Keep imported text plain, use approved product facts and supply publicly reachable photo URLs.
Per-recipient values take precedence over campaign values, which take precedence over defaults in the JSON design. The bulk rendering documentation explains that merge order and the request limits.
3. Validate both recipients and read the estimate
Run:
node src/index.js --dry-run --limit 2 --out out/sample --max-credits 22
The script resolves each recipient's values and calls Zvid's validation endpoint. This does not create jobs or consume render credits. The supplied example prints:
CSV row 1 (offer-amira-r1): 11 credits
CSV row 2 (offer-omar-r2): 11 credits
Estimate: 22 credits for 2 video(s).
It also saves out/sample/validation.json, including any layout warnings. Fix invalid fields and inspect warnings before rendering. A successful validation checks the request; it cannot prove that a product photo will remain available or that your revised text looks good in every scene.
The --max-credits 22 option refuses a new run whose validation estimate exceeds 22. It is a check in this example script, not a server-enforced spending limit. If you change the design or number of rows, review the new estimate and choose an appropriate limit. Credits and plans explains how render charges and account limits work.
4. Submit the batch and download the videos
After reviewing the estimate, run the same selection without --dry-run:
node src/index.js --limit 2 --out out/sample --max-credits 22
This command spends render credits. It validates again, submits the two recipients and saves the returned batch and job IDs before polling for completion. Each accepted row becomes its own render job; an accepted batch is not yet a finished video.
The important part of the request is small. In src/run.js, the complete JSON project goes into payload, shared values go into variables, and each recipient becomes an item:
const response = await client.renders.createBulk({
payload,
variables,
items: selected.map(({ name, variables }) => ({
name,
variables,
Collapse example
const response = await client.renders.createBulk({
payload,
variables,
items: selected.map(({ name, variables }) => ({
name,
variables,
})),
name: `personalized-offers ${state.createdAt}`,
});
The supplied runner handles the rest: it saves response.bulkId, checks the batch status with client.renders.getBulk() until the accepted jobs finish, and downloads completed videos. It also records any item the API rejects instead of treating the whole batch as successful.
After a successful run, open:
out/sample/
validation.json
run.json
results.csv
offer-amira-r1.mp4
offer-omar-r2.mp4
Watch both MP4s from beginning to end. Check the greeting, product image and description, discount, coupon code, transitions and music. results.csv connects each original CSV row to its batch ID, job ID, final status, output URL, credit consumption and local filename. An error appears in the same row when work needs attention.
The text arcadia.example/amira inside the video is not clickable. Put the real destination in the surrounding email, landing page or platform link field. This example creates and downloads videos; your application owns delivery to the recipient.
5. Resume the saved jobs if the process stops
If polling times out, your terminal closes or a completed video's download fails, keep the output folder and run:
node src/index.js --resume --out out/sample
Resume reads run.json, checks the saved batches and downloads missing outputs. It does not submit another render. The default 30-minute polling deadline only stops the local wait; it does not cancel the server's jobs. Run one process at a time for an output directory.
A different case needs manual inspection: the submission connection can fail before the response's batch ID reaches your machine. The journal then records submission_unknown. Check your dashboard before creating another paid run. Resume cannot recover a batch ID it never received, and the runner deliberately avoids automatic submission retries.
| What happened | Next action |
|---|---|
| Missing CSV field or invalid image URL scheme | Correct the indicated row; no selected jobs are submitted by this local check. |
| Validation fails or the estimate exceeds your limit | Read validation.json, fix the inputs or review a new limit, then validate again. |
| Some items are accepted and others rejected | Keep the accepted job IDs. Correct only rejected rows in a separate CSV for a new run. |
| A render fails | Read its error in results.csv, fix the cause and intentionally render only the affected row again. |
| A completed MP4 fails to download | Use --resume for the same folder; no new render is needed. |
| Submission has no confirmed batch ID | Inspect the dashboard before deciding whether to submit again. |
Use a new output directory for each intentional new campaign. For example, after preparing and validating a corrected data/retry.csv, render it to out/retry-1. Reusing out/sample for a new submission is refused so its saved job tracking is not overwritten.
Adapt one offer before increasing the batch
Change one recipient's values first. Keep product names and taglines close to the sample lengths, confirm that the photo survives the vertical crop, and watch the full result. For a design change, edit template.json or build a composition in the Zvid editor. For campaign-wide wording or color, edit campaign.json.
When the small sample works, remove --limit 2, use a new output directory and validate the full selection. The script defaults to 100 items per request. Zvid's hard limit is 500 items per bulk request, and your account may allow fewer; reduce --batch-size if the API reports a lower limit.
Download finished outputs promptly. Render-output links expire, so copy videos to storage you control before using them in a long-lived campaign or web page. Updating a CSV, price or coupon does not change an MP4 that already exists; generate and approve a replacement.
For a wider choice between inline projects and stored templates, read JSON to Video API: Projects, Templates and Bulk Rendering. If the source of truth is a Shopify catalog and the destination is the product page, follow Shopify Product Video.
Frequently asked questions
Can I use the product video API without creating a stored Zvid template?
Yes. This example submits the full video project in payload, with shared and per-recipient variables. The file named template.json is a local project design, not a stored template ID.
How many credits do the two personalized offer videos use?
The supplied Amira and Omar examples use 11 render credits each, 22 in total. Run the validation command to check the current estimate before submitting, especially after changing the design or inputs. Validation itself does not consume render credits.
Does the resume command create duplicate videos?
No. This example's --resume command only polls saved batches and downloads completed outputs. It never submits a render. If the original submission response was lost before a batch ID was saved, inspect the dashboard before starting another paid run.
Why does the script say that a run already exists?
The output directory already contains run.json. Use --resume to continue that run, or choose a new --out directory for a deliberate new campaign. Keep the existing journal so accepted jobs remain traceable.
Does this example email videos or create store coupon codes?
No. The script creates and downloads MP4s using the recipient and coupon values you supply. Your application must select recipients, create valid offers and deliver the reviewed videos. Shop text inside an MP4 is not a clickable link.