Sora 1 shut down 2 days ago. If your listing-video pipeline used Sora, this playbook is your drop-in replacement — use code SORAPROPERTY for 200 bonus credits.
Tutorials/Listing Photo to Video
Real Estate Playbook10 min setupAPI-ready today

Listing Photo → Video with Async Webhook Delivery

Turn listing photos into walkthrough-style videos via POST /v1/video/generations with image_url + webhook_url. Built for property platforms, listing-video services, and real-estate media teams.

Why property platforms need this

Listing photo → video in one call

Submit the MLS hero photo and a motion prompt. Get a 5-second walkthrough clip delivered by webhook — no polling loop required.

Webhook callbacks for production

Signed webhook delivery means your platform knows the instant a video is ready, without long-polling or cron jobs.

Per-listing traceability

Track listing_id + generation_id together so completed videos route to the correct property page, MLS upload, or social ad.

Clear status for agents & teams

Map render states to agent-friendly language so no one asks 'where is my video?' while it renders.

Unit economics

Cost math for a listing-video service

Simple per-listing economics agents and platform builders can quote immediately.

Enhance one listing photo
3 credits · ~$0.21–$0.30
Optional: regenerate or enhance the hero shot before converting to video. Skip if the source photo is already approved.
Render one 5s listing video
5 credits · ~$0.36–$0.50
Submit the listing photo to /v1/video/generations with a walkthrough motion prompt. Delivered via webhook.
Full photo → video workflow
5–8 credits · ~$0.36–$0.80
One listing video per property room. Most listings skip the image step and go straight from MLS photo to video.
Credits start at $0.10 each and drop to ~$0.071 at the largest pack. A 10-room listing at 5 credits/video costs roughly $3.50–$5.00 total — well under the $29–$49/mo plans most listing-video competitors charge for limited renders.
1

Prepare your listing manifest

One row per room or listing photo. Include the MLS image URL, a motion prompt describing the camera move, and the delivery target (MLS upload, website hero, social ad).

csv
listing_id,image_url,video_prompt,property_type,delivery_target
LST-4201,https://cdn.example.com/listings/4201-living-room.jpg,"5-second cinematic walkthrough, warm afternoon light, smooth camera dolly forward",residential,mls-upload
LST-4202,https://cdn.example.com/listings/4202-kitchen.jpg,"5-second kitchen reveal, natural light from windows, slow pan left to right",residential,website-hero
LST-4203,https://cdn.example.com/listings/4203-exterior.jpg,"5-second aerial approach, golden hour, gentle zoom toward front entrance",luxury,social-ad
2

Submit listing videos with bounded concurrency

This Node.js script reads the manifest, submits async video jobs with webhook_url, and writes a JSONL job log you reconcile when callbacks arrive.

javascript
import fs from "node:fs/promises";

const API_KEY = process.env.CREATIVEAI_API_KEY;
const WEBHOOK_URL = "https://your-platform.com/webhooks/listing-video";
const INPUT_CSV = "listing-videos.csv";
const OUTPUT_JSONL = "listing-video-jobs.jsonl";
const MAX_CONCURRENCY = 3;

function parseCsv(csv) {
  const [headerLine, ...lines] = csv.trim().split("\n");
  const headers = headerLine.split(",").map((v) => v.trim());

  return lines
    .filter(Boolean)
    .map((line) => {
      const values = line
        .match(/("[^"]*"|[^,]+)/g)
        ?.map((v) => v.replace(/^"|"$/g, "").trim()) ?? [];
      return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? ""]));
    });
}

async function submitListingVideo(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 {
    listing_id: row.listing_id,
    property_type: row.property_type,
    delivery_target: row.delivery_target,
    generation_id: body.id,
    status: body.status,
    submitted_at: new Date().toISOString(),
  };
}

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] = {
          listing_id: rows[current].listing_id,
          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, submitListingVideo, MAX_CONCURRENCY);

  await fs.writeFile(
    OUTPUT_JSONL,
    results.map((r) => JSON.stringify(r)).join("\n") + "\n",
    "utf8"
  );

  const submitted = results.filter((r) => r.status === "pending").length;
  console.log("Submitted " + submitted + "/" + rows.length + " listing videos -> " + OUTPUT_JSONL);
}

main().catch((err) => { console.error(err); process.exit(1); });
3

Receive the webhook and route the video

Verify the HMAC signature, match the generation_id to the listing, and push the completed video to your MLS, CMS, or property website.

javascript
import crypto from "node:crypto";
import fs from "node:fs/promises";

const API_KEY = process.env.CREATIVEAI_API_KEY;
const JOBS_PATH = "listing-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("\n");
  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,
          completed_at: event.completed_at || null,
          error: event.error || null,
        }
      : job
  );

  await fs.writeFile(
    JOBS_PATH,
    updated.map((j) => JSON.stringify(j)).join("\n") + "\n",
    "utf8"
  );

  if (event.status === "completed") {
    // Push event.output_url to your MLS, website CMS, or listing platform here.
  }

  return Response.json({ ok: true });
}
4

Track per-listing delivery state

Store listing ID, job ID, output URL, and error context together. Failed listings can be retried without re-submitting the full batch.

jsonl
{"listing_id":"LST-4201","property_type":"residential","delivery_target":"mls-upload","generation_id":"vgen_abc123","status":"completed","output_url":"https://cdn.creativeai.run/output/video-abc123.mp4","completed_at":"2026-03-12T09:22:17Z","error":null}
{"listing_id":"LST-4202","property_type":"residential","delivery_target":"website-hero","generation_id":"vgen_def456","status":"pending","submitted_at":"2026-03-12T09:20:01Z"}
{"listing_id":"LST-4203","property_type":"luxury","delivery_target":"social-ad","generation_id":"vgen_ghi789","status":"failed","error":"provider timeout after retry budget"}
5

Give agents clear status language

Real-estate agents and property managers should never see raw API states. Map each status to language they understand.

json
[
  {
    "status": "pending",
    "agent_copy": "Queued — video render slot reserved, no action needed"
  },
  {
    "status": "processing",
    "agent_copy": "Rendering listing video — we'll notify you when it's ready"
  },
  {
    "status": "completed",
    "agent_copy": "Listing video ready — download or publish to MLS / website"
  },
  {
    "status": "failed",
    "agent_copy": "Render failed — retry or contact support with the job ID"
  }
]

Operational notes

1. Start with MAX_CONCURRENCY = 3 and increase after verifying quota and webhook throughput on your hosting.
2. Keep listing_id + generation_id paired in your job store so retries don't duplicate renders.
3. For multi-room listings, submit one job per room photo and group results by listing_id before publishing.
4. Use /webhooks as the proof page for webhook reliability if buyers ask about delivery guarantees.
5. Set spend limits on API keys before handing the pipeline to photographers, agents, or partner platforms.

Property marketing overview

Send /for-property-marketing first when the prospect wants pricing, staging, and video in one page.

Webhook reliability proof

Send /webhooks when the objection is retries, signing, failover, or polling fallback.

Multi-room consistency

Send /tutorials/multi-room-multi-angle-consistency when the prospect needs consistent style across rooms.

Also using DALL-E for listing photos?DALL-E 3 shuts down in 58 days

DALL-E 2 & 3 shut down May 12, 2026 — 58 days away. If you use DALL-E for virtual staging or listing photo generation, you're about to lose both your image and video backends. CreativeAI covers both — one API key for the full pipeline.

Use code DALLE1000 for bonus credits on your DALL-E migration.

Ready to add listing videos to your platform?

Sign up for free credits, copy the code above, and submit your first listing photo → video job in minutes. Use code SORAPROPERTY at signup for 200 bonus credits — enough for 40+ listing videos.