Tutorials/Shopify Product Video Webhooks
Sales Playbook12 min setupAPI-ready today

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.

Unit economics

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.

Refresh one PDP still
3 credits Β· ~$0.21–$0.30
Generate or refresh a hero image before motion. Useful when the source photo needs a cleaner scene first.
Render one 5s PDP clip
5 credits Β· ~$0.36–$0.50
Submit the approved still to /v1/video/generations and deliver the result by signed webhook.
Typical still β†’ video workflow
8 credits Β· ~$0.57–$0.80
One image plus one 5-second clip per SKU. Clean, transparent math for partner or white-label buyers.
Credits start at $0.10 each and drop to about $0.071 at the largest pack, so your exact per-SKU cost depends on how you buy credits β€” but the workflow math stays predictable.
1

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.

csv
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
2

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.

javascript
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);
});
3

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.

javascript
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 });
}
4

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.

jsonl
{"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"}
5

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.

json
[
  {
    "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"
  }
]
6

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.

javascript
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];
}
Integration noteCall 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

1. Start with MAX_CONCURRENCY = 4 and increase only after checking quota, webhook throughput, and review bandwidth.
2. Persist sku + generation_id together so failed rows can be replayed without losing traceability.
3. Use /webhooks as the proof page for delivery reliability and fall back to polling if the buyer cannot receive inbound HTTP.
4. Keep Shopify/CMS publish state separate from render state so QA can approve outputs before they hit PDPs or ads.
5. Set spend limits on API keys before handing the workflow to agencies, operators, or client teams.

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.

Async webhook deliverySKU-level traceabilityShopify/PDP ready