Shopify Product Videos at Catalog Scale with Async Webhooks
Start from product hero images, submit bounded batches to POST /v1/video/generations, and let webhook delivery update Shopify, your CMS, or an ad pipeline as each render completes.
Why this closes current buyer objections
Catalog-scale motion
Turn winning PDP stills into short product videos without building a queue system from scratch.
Async delivery buyers can trust
Submit once, receive signed webhook callbacks when each render completes, and keep polling as a fallback.
Partner-ready handoff
Keep SKU-level traceability so agencies, Shopify apps, and white-label tools can safely ingest results downstream.
Clear status copy
Map raw job states to buyer-facing language so users are not stuck staring at vague 'Starting...' UI.
Fast cost math for a Shopify buyer
The workflow is easy to explain in commercial terms: one refreshed still, one async clip, and a total per-SKU cost buyers can understand fast.
Prepare your input manifest
Keep one row per SKU or campaign asset. Use the product image that already won QA, then give each row a motion prompt and downstream target surface.
sku,image_url,video_prompt,collection,target_surface SKU-1001,https://cdn.example.com/catalog/mug-hero.jpg,"5-second product hero shot, slow camera push, clean white sweep",new-arrivals,shopify-pdp SKU-1002,https://cdn.example.com/catalog/shoe-hero.jpg,"5-second product reveal, gentle rotation, dynamic shadow",spring-drop,shopify-pdp SKU-1003,https://cdn.example.com/catalog/bag-hero.jpg,"5-second lifestyle motion, soft natural light, premium editorial feel",paid-social,meta-ads
Submit bounded async batches
This Node.js script reads the CSV, submits a safe number of concurrent video jobs, and writes a JSONL job manifest you can reconcile later when webhooks arrive.
import fs from "node:fs/promises";
const API_KEY = process.env.CREATIVEAI_API_KEY;
const WEBHOOK_URL = "https://your-app.com/webhooks/creativeai";
const INPUT_CSV = "product-videos.csv";
const OUTPUT_JSONL = "video-jobs.jsonl";
const MAX_CONCURRENCY = 4;
function parseCsv(csv) {
const [headerLine, ...lines] = csv.trim().split("
");
const headers = headerLine.split(",").map((value) => value.trim());
return lines
.filter(Boolean)
.map((line) => {
const values = line
.match(/("[^"]*"|[^,]+)/g)
?.map((value) => value.replace(/^"|"$/g, "").trim()) ?? [];
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""]));
});
}
async function submitVideo(row) {
const response = await fetch("https://api.creativeai.run/v1/video/generations", {
method: "POST",
headers: {
Authorization: "Bearer " + API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "kling-v3",
prompt: row.video_prompt,
image_url: row.image_url,
duration: "5s",
aspect_ratio: "16:9",
webhook_url: WEBHOOK_URL,
}),
});
const body = await response.json();
if (!response.ok) {
throw new Error("HTTP " + response.status + ": " + JSON.stringify(body));
}
return {
sku: row.sku,
collection: row.collection,
target_surface: row.target_surface,
generation_id: body.id,
status: body.status,
};
}
async function runWithConcurrency(rows, worker, limit) {
const results = [];
let index = 0;
async function runner() {
while (index < rows.length) {
const current = index++;
try {
results[current] = await worker(rows[current]);
} catch (error) {
results[current] = {
sku: rows[current].sku,
status: "submit_failed",
error: String(error),
};
}
}
}
await Promise.all(Array.from({ length: Math.min(limit, rows.length) }, () => runner()));
return results;
}
async function main() {
if (!API_KEY) throw new Error("Set CREATIVEAI_API_KEY first");
const csv = await fs.readFile(INPUT_CSV, "utf8");
const rows = parseCsv(csv);
const results = await runWithConcurrency(rows, submitVideo, MAX_CONCURRENCY);
await fs.writeFile(
OUTPUT_JSONL,
results.map((result) => JSON.stringify(result)).join("
") + "
",
"utf8"
);
const submitted = results.filter((result) => result.status === "pending").length;
console.log("Submitted " + submitted + "/" + rows.length + " jobs -> " + OUTPUT_JSONL);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});Handle the webhook and update your catalog state
Verify the HMAC signature, find the matching generation_id, and update your job store with output URL, failure context, and whether failover was used.
import crypto from "node:crypto";
import fs from "node:fs/promises";
const API_KEY = process.env.CREATIVEAI_API_KEY!;
const JOBS_PATH = "video-jobs.jsonl";
function verifySignature(rawBody, signatureHeader) {
const expected = crypto
.createHmac("sha256", API_KEY)
.update(rawBody)
.digest("hex");
const received = (signatureHeader || "").replace("sha256=", "");
if (!received) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received)
);
}
export async function POST(request) {
const rawBody = await request.text();
const signature = request.headers.get("x-creativeai-signature");
if (!verifySignature(rawBody, signature)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(rawBody);
const lines = (await fs.readFile(JOBS_PATH, "utf8")).trim().split("
");
const jobs = lines.map((line) => JSON.parse(line));
const updated = jobs.map((job) =>
job.generation_id === event.id
? {
...job,
status: event.status,
output_url: event.output_url || null,
failover_used: Boolean(event.failover_used),
completed_at: event.completed_at || null,
error: event.error || null,
}
: job
);
await fs.writeFile(
JOBS_PATH,
updated.map((job) => JSON.stringify(job)).join("
") + "
",
"utf8"
);
if (event.status === "completed") {
// Push event.output_url into Shopify metafields, your CMS, or an ad pipeline here.
}
return Response.json({ ok: true });
}Persist an output manifest operations can trust
Store the SKU, job ID, result URL, and error context together. That makes retries deterministic and gives Sales/Ops a concrete proof artifact for buyer conversations.
{"sku":"SKU-1001","collection":"new-arrivals","target_surface":"shopify-pdp","generation_id":"vgen_abc123","status":"completed","output_url":"https://cdn.creativeai.run/output/video-abc123.mp4","failover_used":false,"completed_at":"2026-03-11T08:44:03Z","error":null}
{"sku":"SKU-1002","collection":"spring-drop","target_surface":"shopify-pdp","generation_id":"vgen_def456","status":"pending"}
{"sku":"SKU-1003","collection":"paid-social","target_surface":"meta-ads","generation_id":"vgen_ghi789","status":"failed","error":"provider timeout after retry budget"}Give buyers better status language than 'Starting...'
One common objection from Shopify video tools is weak visibility while a render sits in queue. Translate provider states into plain language your customers actually understand.
[
{
"status": "pending",
"buyer_copy": "Queued with provider - render slot reserved"
},
{
"status": "processing",
"buyer_copy": "Rendering video - we'll deliver it by webhook automatically"
},
{
"status": "completed",
"buyer_copy": "Video ready - safe to publish to PDP, ads, or email"
},
{
"status": "failed",
"buyer_copy": "Render failed - retry or fall back to polling/debug flow"
}
]Publish the video to your Shopify product
When the webhook fires with status: "completed", push the rendered video directly onto the Shopify product using the Admin GraphQL API. This closes the loop: product image β CreativeAI video β live on PDP.
import crypto from "node:crypto";
const SHOPIFY_STORE = "your-store.myshopify.com";
const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
// Called from your webhook handler when event.status === "completed"
async function publishVideoToShopify(sku, videoUrl) {
// Step 1: Look up the Shopify product by SKU
const searchResp = await fetch(
"https://" + SHOPIFY_STORE + "/admin/api/2024-10/graphql.json",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": SHOPIFY_TOKEN,
},
body: JSON.stringify({
query: `{
products(first: 1, query: "sku:${sku}") {
edges { node { id title } }
}
}`,
}),
}
);
const { data } = await searchResp.json();
const product = data.products.edges[0]?.node;
if (!product) throw new Error("No Shopify product for SKU " + sku);
// Step 2: Create external video media on the product
const mediaResp = await fetch(
"https://" + SHOPIFY_STORE + "/admin/api/2024-10/graphql.json",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": SHOPIFY_TOKEN,
},
body: JSON.stringify({
query: `mutation createMedia($productId: ID!, $media: [CreateMediaInput!]!) {
productCreateMedia(productId: $productId, media: $media) {
media { id status }
mediaUserErrors { field message }
}
}`,
variables: {
productId: product.id,
media: [
{
originalSource: videoUrl,
mediaContentType: "EXTERNAL_VIDEO",
alt: product.title + " β product video",
},
],
},
}),
}
);
const result = await mediaResp.json();
const errors = result.data?.productCreateMedia?.mediaUserErrors || [];
if (errors.length) throw new Error("Shopify media error: " + JSON.stringify(errors));
console.log("Published video to " + product.title + " (" + sku + ")");
return result.data.productCreateMedia.media[0];
}publishVideoToShopify(event.sku, event.output_url) inside your Step 3 webhook handler where the placeholder comment is. For production apps, add the publish call to a short queue so webhook responses stay fast and retryable.Operational notes
MAX_CONCURRENCY = 4 and increase only after checking quota, webhook throughput, and review bandwidth.sku + generation_id together so failed rows can be replayed without losing traceability./webhooks as the proof page for delivery reliability and fall back to polling if the buyer cannot receive inbound HTTP.Best first proof page
Send /for-catalog-sellers first when the prospect wants one link covering batch stills, video, fidelity, and pricing.
Best reliability proof
Send /webhooks when the objection is retries, signing, failover, or polling fallback.
Best fidelity proof
Send /reference-fidelity-api or the text/logo QA tutorial when the concern is packaging, labels, or product consistency.
Ready to pitch or ship the workflow?
Use this page as the implementation handoff, then pair it with the public proof pages below during outreach.