DEV Community

PopNow
PopNow

Posted on AI-assisted

How to Generate AI Videos from Code with One API (Node.js + Python Examples)

Generating an image from code is usually one request: you send a prompt and get a picture back a few seconds later. Video is different. A five-second clip can take minutes to render, so a single HTTP call that blocks until the file is ready is the wrong shape. What you need is a small job flow: submit the work, track it, collect the result.

This tutorial builds that flow end to end with plain fetch in Node.js and requests in Python. The examples use the PopNow API, which puts many video models (Wan, MiniMax, Kling, Seedance, Vidu and others) behind one request format, so switching models is a one-line change. The pattern itself — create a task, poll or receive a webhook, download before the link expires — carries over to most async generation APIs.

What you'll build

A script that:

  1. Asks for the price of a clip before running it.
  2. Creates a text-to-video task.
  3. Polls until the task is finished.
  4. Downloads the MP4 to disk.

After that we'll animate an image instead, switch to a second model, and look at what changes when you move from a script to production.

Prerequisites

  • Node.js 18+ or Python 3 with requests.
  • A PopNow account and an API key. Keys are created in the API console and start with sk-popnow-. The API spends the same credits as the website; there is no separate API plan and no subscription. You buy a one-time credit pack (packs start at $9) and pay as you go. One credit is $0.001.
export POPNOW_API_KEY="sk-popnow-..."
Enter fullscreen mode Exit fullscreen mode

Keep the key on your server. It spends real credits, so it should never end up in a browser bundle or a mobile app.

The request shape

Every generation — image, video or audio — goes through the same endpoint:

POST https://api.popnow.ai/api/v1/jobs/createTask
Authorization: Bearer $POPNOW_API_KEY
Idempotency-Key: <unique id for this job>
Enter fullscreen mode Exit fullscreen mode

The body has two required fields:

{
  "model": "alibaba/wan-3.0/text-to-video",
  "input": {
    "prompt": "A paper boat drifts down a rain-soaked street at dusk, neon signs reflecting in the water, slow tracking shot at ground level",
    "resolution": "480p",
    "duration_seconds": 5,
    "aspect_ratio": "16:9"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • model picks both the model and the job: text-to-video, image-to-video, video-extend and so on.
  • input is validated against that model's own schema. Unknown fields are rejected, so a typo like duraton_seconds fails immediately instead of being silently ignored.
  • Idempotency-Key makes retries safe. If a request times out and you resend it with the same key, you get the original task back instead of a second charge.

Every JSON response uses the same envelope: { "code", "msg", "data", "request_id" }. Note that code is a business code, not the HTTP status. 200 means success; anything else is an error. Branch on code, never on the wording of msg, and log request_id so you can quote it if you need support.

Step 1: Ask for the price first

Video is priced per second of output, and the rate depends on resolution. For Wan 3.0 text-to-video it is 45 credits per second at 480p, 90 at 720p and 180 at 1080p. Instead of hard-coding a price table, ask the API. POST /jobs/quote takes exactly the body you would send to createTask and returns the price without creating a task or holding any credits:

curl -X POST "https://api.popnow.ai/api/v1/jobs/quote" \
  -H "Authorization: Bearer $POPNOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"alibaba/wan-3.0/text-to-video","input":{"prompt":"A paper boat drifts down a rain-soaked street","resolution":"480p","duration_seconds":5}}'
Enter fullscreen mode Exit fullscreen mode

The response carries maxCredits (the most the request can cost, which is the amount that will be held from your balance) and expectedCredits (what it usually costs). Five seconds at 480p is 5 × 45 = 225 credits, or $0.225.

If you show that price to an end user and want it to stick, pass the quoteId and the maxCredits you showed (as expectedCredits) in createTask within five minutes. If anything changed in between, the API answers 40901 and holds nothing, so you can quote again.

Step 2: Create, poll and download in Node.js

Save this as video.mjs and run it with node video.mjs. It uses only built-in modules.

// video.mjs: text-to-video, end to end
import { randomUUID } from 'node:crypto';
import { writeFile } from 'node:fs/promises';

const API = 'https://api.popnow.ai/api/v1';
const headers = {
  Authorization: `Bearer ${process.env.POPNOW_API_KEY}`,
  'Content-Type': 'application/json',
};

const job = {
  model: 'alibaba/wan-3.0/text-to-video',
  input: {
    prompt:
      'A paper boat drifts down a rain-soaked street at dusk, neon signs ' +
      'reflecting in the water, slow tracking shot at ground level',
    resolution: '480p',
    duration_seconds: 5,
    aspect_ratio: '16:9',
  },
};

// Small helper: unwrap the { code, msg, data, request_id } envelope.
async function call(path, init = {}) {
  const res = await fetch(`${API}${path}`, {
    ...init,
    headers: { ...headers, ...init.headers },
  });
  const body = await res.json();
  if (body.code !== 200) {
    throw new Error(`${body.code} ${body.msg} (request ${body.request_id})`);
  }
  return body.data;
}

// 1. Price check.
const quote = await call('/jobs/quote', { method: 'POST', body: JSON.stringify(job) });
console.log(`at most ${quote.maxCredits} credits ($${quote.maxCharge})`);

// 2. Create the task. In a real app, store this key with your own job record
//    so a retry after a timeout returns the same task.
const created = await call('/jobs/createTask', {
  method: 'POST',
  headers: { 'Idempotency-Key': randomUUID() },
  body: JSON.stringify(job),
});
console.log(`task ${created.taskId} is ${created.state}`);

// 3. Poll until the state is final. Unknown states count as "not final yet".
const FINAL = new Set(['succeeded', 'failed', 'expired']);
let task;
do {
  await new Promise((r) => setTimeout(r, 5000));
  task = await call(`/jobs/recordInfo?taskId=${encodeURIComponent(created.taskId)}`);
  process.stdout.write(`\r${task.state}...   `);
} while (!FINAL.has(task.state));
console.log();

if (task.state !== 'succeeded') {
  throw new Error(`task ${task.state}: ${task.errorCode ?? ''} ${task.errorMessage ?? ''}`);
}

// 4. Download right away: the signed link is short-lived.
const asset = task.output.assets[0];
const file = await fetch(asset.url);
await writeFile('clip.mp4', Buffer.from(await file.arrayBuffer()));
console.log(`saved clip.mp4 (${asset.durationSeconds}s, ${asset.width}x${asset.height}), charged ${task.credits} credits`);
Enter fullscreen mode Exit fullscreen mode

A few things worth knowing about what just happened:

  • createTask returns HTTP 202, not the video. 202 means accepted. The response has a taskId, state: "queued" and estimatedCredits, which is the hold: the most this task can cost.
  • You pay for what was used. When the task succeeds, you are charged the actual amount, rounded up to whole credits and never more than the hold; the rest goes back to your balance. When a task fails or expires, the hold is released.
  • States. A task moves from queued to running and ends as succeeded, failed or expired. On failure, branch on errorCode and show errorMessage.
  • Signed links expire. output.assets[0].url works for about 20 minutes and needs no API key. The file itself is still there afterwards: read the task again with recordInfo to get a fresh link. Store the taskId, not the URL.

For images you can skip the polling loop: add ?wait=60 to createTask and the connection stays open for up to 60 seconds, so a quick image comes back in the same response. Video almost always takes longer than that, which is why this script polls.

The same flow in Python

# video.py: pip install requests, then python video.py
import os
import time
import uuid

import requests

API = "https://api.popnow.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['POPNOW_API_KEY']}"}
FINAL = {"succeeded", "failed", "expired"}


def call(method, path, **kwargs):
    """Send a request and unwrap the {code, msg, data, request_id} envelope."""
    headers = {**HEADERS, **kwargs.pop("headers", {})}
    body = requests.request(method, f"{API}{path}", headers=headers, timeout=30, **kwargs).json()
    if body["code"] != 200:
        raise RuntimeError(f'{body["code"]} {body["msg"]} (request {body.get("request_id")})')
    return body["data"]


job = {
    "model": "alibaba/wan-3.0/text-to-video",
    "input": {
        "prompt": "A paper boat drifts down a rain-soaked street at dusk, neon signs "
                  "reflecting in the water, slow tracking shot at ground level",
        "resolution": "480p",
        "duration_seconds": 5,
        "aspect_ratio": "16:9",
    },
}

quote = call("POST", "/jobs/quote", json=job)
print(f'at most {quote["maxCredits"]} credits (${quote["maxCharge"]})')

created = call("POST", "/jobs/createTask", json=job,
               headers={"Idempotency-Key": str(uuid.uuid4())})
print("task", created["taskId"], "is", created["state"])

while True:
    time.sleep(5)
    task = call("GET", "/jobs/recordInfo", params={"taskId": created["taskId"]})
    if task["state"] in FINAL:
        break

if task["state"] != "succeeded":
    raise SystemExit(f'task {task["state"]}: {task.get("errorCode")} {task.get("errorMessage")}')

asset = task["output"]["assets"][0]
with requests.get(asset["url"], stream=True, timeout=120) as r:
    r.raise_for_status()
    with open("clip.mp4", "wb") as f:
        for chunk in r.iter_content(chunk_size=1 << 20):
            f.write(chunk)
print("saved clip.mp4, charged", task["credits"], "credits")
Enter fullscreen mode Exit fullscreen mode

Animate an image instead

Image-to-video is the same code with a different model and input:

const job = {
  model: 'alibaba/wan-3.0/image-to-video',
  input: {
    image_url: 'https://media.popnow.ai/movie/lighthouse.webp',
    prompt: 'Waves roll in slowly, clouds drift, the camera pushes gently toward the lighthouse',
    resolution: '480p',
    duration_seconds: 5,
  },
};
Enter fullscreen mode Exit fullscreen mode

image_url is the opening frame. Many image-to-video models also accept last_image_url: the clip then ends exactly on that image. Use the same picture for both and you get a smooth loop, which is handy for product shots and background videos.

Switch models with one line

Because every model sits behind the same endpoint, trying another one is a matter of changing model. Here is MiniMax H3's text-to-video:

const job = {
  model: 'minimax/h3/text-to-video',
  input: {
    prompt: 'A paper boat drifts down a rain-soaked street at dusk, slow tracking shot at ground level',
    resolution: '768p',
    duration_seconds: 5,
  },
};
Enter fullscreen mode Exit fullscreen mode

Notice that the resolution values are different: H3 takes 768p or 2k and 5 to 15 seconds, while Wan 3.0 takes 480p, 720p or 1080p and up to 30 seconds. That is where schema validation pays off. If you swap the model and forget to change 480p, the request is rejected up front instead of running with a setting you didn't ask for. H3 costs 37 credits per second at 768p, so this clip is 185 credits.

To see every model ID with its price from code, call GET /api/v1/models.

Going to production: webhooks instead of polling

Polling every few seconds is fine for a script. In an app with many users, let the API call you instead. Add a callBackUrl (a public HTTPS URL) to the createTask body, and the finished task is POSTed to it.

Deliveries are signed with your account's webhook secret, which you generate in the API console:

signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )
Enter fullscreen mode Exit fullscreen mode

The signature arrives in X-Webhook-Signature and the timestamp in X-Webhook-Timestamp. Hash the raw bytes you received, not a re-serialized object. Here is a compact Express receiver:

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.POPNOW_WEBHOOK_SECRET;

app.post('/hooks/popnow', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
  const ts = Number(req.headers['x-webhook-timestamp']);
  const sig = String(req.headers['x-webhook-signature'] ?? '');
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end();

  let event;
  try { event = JSON.parse(req.body.toString('utf8')); } catch { return res.status(400).end(); }
  const digest = crypto.createHash('sha256').update(req.body).digest('hex');
  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${event.data?.taskId}.${ts}.${digest}`).digest('base64');
  const ok = expected.length === sig.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  if (!ok) return res.status(401).end();

  res.status(200).end();                   // acknowledge first...
  console.log(event.data.taskId, event.data.state); // ...then queue the real work
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

A few rules make webhook handlers robust:

  • Answer fast. Any 2xx within 15 seconds counts as received. Download and transcode in a background job.
  • Expect duplicates. Failed deliveries are retried, so the same task can arrive twice. request_id in the body is the delivery ID and stays the same across retries; store it with a unique index and ignore repeats.
  • The webhook says "delivered", not "succeeded". code is 200 even for a failed task. Read data.state.
  • Reconcile. Once a day, list recent tasks with GET /api/v1/jobs and read any whose webhook you never received.

And a few that apply whether you poll or not:

  • Derive the Idempotency-Key from your own job ID, so a retry can never create a second task.
  • Copy finished files into your own storage. Outputs are kept for a limited time; the task record says when in retention.outputsExpireAt.
  • Tune prompts at the lowest resolution and a short duration, then raise both for the final render.

A note on prompts

Video models reward prompts that describe motion, not just the scene. "A beautiful lighthouse" gives the model nothing to do. "Waves roll in slowly, clouds drift, the camera pushes gently toward the lighthouse" tells it what moves, how fast, and what the camera does. With image-to-video this matters even more: the picture already shows what things look like, so spend your words on what changes.

Wrapping up

The whole integration comes down to three calls — jobs/quote, jobs/createTask and jobs/recordInfo — plus a webhook once you go to production. Since the request shape is the same for every model, you can write this code once and compare models by changing a single string.

The full reference, including uploads for your own images, error codes and per-model parameters, is in the PopNow API docs.

Top comments (0)