Generate and Download a Video from Text

Submit a text-to-video job, poll for completion, and save the generated MP4

Use this guide when you need to add text-to-video generation to an app with OpenRouter.

By the end, your implementation should submit a video job, poll for completion, and download the generated MP4.

For reusable agent knowledge across projects, install the openrouter-video skill.

Before you start

You need:

  • An OpenRouter API key available as OPENROUTER_API_KEY
  • Node.js 20 or newer
  • A video model slug, such as google/veo-3.1-lite

If you have not chosen a model yet, read Choose a Video Generation Model so you can select one based on your clip duration, output shape, input type, audio, provider controls, and cost requirements.

Use the API reference pages as the source of truth for exact fields:

Before wiring the submit path, confirm that the selected model supports the duration, resolution, and aspect ratio you plan to send. For example, the model used below returned this metadata during QA:

$node --input-type=module <<'EOF'
$const { data } = await fetch(
> "https://openrouter.ai/api/v1/videos/models",
>).then((response) => response.json());
$const model = data.find((item) => item.id === "google/veo-3.1-lite");
$
$console.log(
> JSON.stringify(
> {
> durations: model.supported_durations,
> resolutions: model.supported_resolutions,
> aspect_ratios: model.supported_aspect_ratios,
> },
> null,
> 2,
> ),
>);
$EOF

Model metadata output:

1{
2 "durations": [8, 4, 6],
3 "resolutions": ["720p", "1080p"],
4 "aspect_ratios": ["16:9", "9:16"]
5}

Submitting POST /api/v1/videos starts a real video generation job and may spend OpenRouter credits.

Step 1: Submit the video job

Add a server-side submit step that sends POST /api/v1/videos with the chosen model, prompt, duration, resolution, and aspect ratio. Store the returned job object because the next step needs its id, status, and polling_url.

Adapt this submit shape in the server route, queue, or worker that owns video generation:

1const apiKey = process.env.OPENROUTER_API_KEY;
2
3if (!apiKey) {
4 throw new Error("Set OPENROUTER_API_KEY first.");
5}
6
7async function openrouter(path: string, init: RequestInit = {}) {
8 const response = await fetch(`https://openrouter.ai/api/v1${path}`, {
9 ...init,
10 headers: {
11 Authorization: `Bearer ${apiKey}`,
12 "Content-Type": "application/json",
13 ...init.headers,
14 },
15 });
16
17 if (!response.ok) {
18 throw new Error(await response.text());
19 }
20
21 return response;
22}
23
24const submitResponse = await openrouter("/videos", {
25 method: "POST",
26 body: JSON.stringify({
27 model: "google/veo-3.1-lite",
28 prompt:
29 "A cinematic 4-second shot of a glass greenhouse at sunrise, soft mist, slow dolly-in camera movement",
30 duration: 4,
31 resolution: "720p",
32 aspect_ratio: "16:9",
33 generate_audio: false,
34 }),
35});
36
37const job = await submitResponse.json();
38console.log(`Submitted video job: ${job.id}`);

A successful submit returns a job id. The QA run produced this shape:

Submitted video job: y34x1YREG4Pkdcj7f02v

Step 2: Poll until the job finishes

Add polling in a server route, queue worker, or background job. Treat completed as success, treat failed, cancelled, and expired as terminal errors, and keep a bounded retry limit so the worker cannot run forever.

Polling logic:

1let status = job;
2
3for (let attempt = 1; attempt <= 60; attempt += 1) {
4 if (status.status === "completed") {
5 break;
6 }
7
8 if (status.status === "failed") {
9 throw new Error(status.error ?? "Video generation failed.");
10 }
11
12 if (["cancelled", "expired"].includes(status.status)) {
13 throw new Error(status.error ?? `Video generation ${status.status}.`);
14 }
15
16 await new Promise((resolve) => setTimeout(resolve, 30_000));
17
18 if (!status.polling_url) {
19 throw new Error("Video job did not include a polling_url.");
20 }
21
22 const pollingUrl = new URL(status.polling_url, "https://openrouter.ai");
23 const pollResponse = await fetch(pollingUrl, {
24 headers: { Authorization: `Bearer ${apiKey}` },
25 });
26
27 if (!pollResponse.ok) {
28 throw new Error(await pollResponse.text());
29 }
30
31 status = await pollResponse.json();
32 console.log(`Status: ${status.status}`);
33}
34
35if (status.status !== "completed") {
36 throw new Error("Video generation did not complete after 60 attempts.");
37}

Completed poll output:

Status: completed

Step 3: Download the video

When polling returns completed, download the first generated asset. The content endpoint is the most direct path; if you use a URL from unsigned_urls, include the bearer token when the URL points back to the OpenRouter API.

In Node.js, import writeFile from node:fs/promises or replace the file write with the storage layer your app uses.

1const videoResponse = await fetch(
2 `https://openrouter.ai/api/v1/videos/${job.id}/content?index=0`,
3 {
4 headers: { Authorization: `Bearer ${apiKey}` },
5 },
6);
7
8if (!videoResponse.ok) {
9 throw new Error(await videoResponse.text());
10}
11
12const videoBuffer = Buffer.from(await videoResponse.arrayBuffer());
13await writeFile("greenhouse.mp4", videoBuffer);
14
15console.log("Saved greenhouse.mp4");

The QA run saved the finished video after polling completed:

Saved greenhouse.mp4

If your completed job includes unsigned_urls, this is the adaptable download shape:

1const videoUrl = status.unsigned_urls?.[0];
2
3const downloadUrl =
4 videoUrl ?? `https://openrouter.ai/api/v1/videos/${job.id}/content?index=0`;
5
6const videoResponse = await fetch(downloadUrl, {
7 headers: downloadUrl.startsWith("https://openrouter.ai/api/")
8 ? { Authorization: `Bearer ${apiKey}` }
9 : undefined,
10});
11
12if (!videoResponse.ok) {
13 throw new Error(await videoResponse.text());
14}
15
16const videoBuffer = Buffer.from(await videoResponse.arrayBuffer());
17await writeFile("greenhouse.mp4", videoBuffer);
18
19console.log("Saved greenhouse.mp4");

Step 4: Put the sequence in your app

Keep the submit, poll, and download steps in the part of your app that owns long-running work. This complete example keeps the pieces together so you can adapt the sequence into a server route, queue, or worker:

1import { writeFile } from "node:fs/promises";
2
3const apiKey = process.env.OPENROUTER_API_KEY;
4
5if (!apiKey) {
6 throw new Error("Set OPENROUTER_API_KEY first.");
7}
8
9async function postJson(path: string, body: unknown) {
10 const response = await fetch(`https://openrouter.ai/api/v1${path}`, {
11 method: "POST",
12 headers: {
13 Authorization: `Bearer ${apiKey}`,
14 "Content-Type": "application/json",
15 },
16 body: JSON.stringify(body),
17 });
18
19 if (!response.ok) {
20 throw new Error(await response.text());
21 }
22
23 return response.json();
24}
25
26const job = await postJson("/videos", {
27 model: "google/veo-3.1-lite",
28 prompt:
29 "A cinematic 4-second shot of a glass greenhouse at sunrise, soft mist, slow dolly-in camera movement",
30 duration: 4,
31 resolution: "720p",
32 aspect_ratio: "16:9",
33 generate_audio: false,
34});
35
36console.log(`Submitted video job: ${job.id}`);
37
38let status = job;
39
40for (let attempt = 1; attempt <= 60; attempt += 1) {
41 if (status.status === "completed") {
42 break;
43 }
44
45 if (status.status === "failed") {
46 throw new Error(status.error ?? "Video generation failed.");
47 }
48
49 if (["cancelled", "expired"].includes(status.status)) {
50 throw new Error(status.error ?? `Video generation ${status.status}.`);
51 }
52
53 await new Promise((resolve) => setTimeout(resolve, 30_000));
54
55 if (!status.polling_url) {
56 throw new Error("Video job did not include a polling_url.");
57 }
58
59 const pollingUrl = new URL(status.polling_url, "https://openrouter.ai");
60 const pollResponse = await fetch(pollingUrl, {
61 headers: { Authorization: `Bearer ${apiKey}` },
62 });
63
64 if (!pollResponse.ok) {
65 throw new Error(await pollResponse.text());
66 }
67
68 status = await pollResponse.json();
69 console.log(`Status: ${status.status}`);
70}
71
72if (status.status !== "completed") {
73 throw new Error("Video generation did not complete after 60 attempts.");
74}
75
76const videoUrl = status.unsigned_urls?.[0];
77const downloadUrl =
78 videoUrl ?? `https://openrouter.ai/api/v1/videos/${job.id}/content?index=0`;
79
80const videoResponse = await fetch(downloadUrl, {
81 headers: downloadUrl.startsWith("https://openrouter.ai/api/")
82 ? { Authorization: `Bearer ${apiKey}` }
83 : undefined,
84});
85
86if (!videoResponse.ok) {
87 throw new Error(await videoResponse.text());
88}
89
90const videoBuffer = Buffer.from(await videoResponse.arrayBuffer());
91await writeFile("greenhouse.mp4", videoBuffer);
92
93console.log("Saved greenhouse.mp4");

Check your work

The job should move from pending or in_progress to completed, and the implementation should produce a playable MP4 from the completed job.