How to Use the Veo 3.1 API: Generate Video with curl via the Google Flow API

6 min read β€’ June 15, 2026 (October 20, 2026)

Table of contents

  1. Introduction
  2. Supported models
  3. Pricing
  4. Generate a video in two API calls
  5. Image-to-video and reference-to-video
    1. First / last frame (I2V)
    2. Reference images (R2V)
  6. Batch-generate with a script
  7. Examples
  8. Frequently asked questions
  9. Conclusion

Introduction

Veo 3.1 runs from a single curl through the useapi.net Google Flow API, on your Google Flow website subscription (Google AI Pro or Ultra). An 8-second Veo 3.1 Fast video costs $0.08 on Google AI Ultra $199.99, against $0.80 on the official Veo API (as of October 2026).

Google shuts down the Veo 3.1 Preview models on the Gemini API on October 22, 2026 and moves developers to its Cloud Agent Platform, which bills the same per-second rates on a Google Cloud project. This third-party API needs no Cloud project, no Google API key and no enterprise approval. Below: the models, the two-call workflow, image inputs and a runnable batch script.

Supported models

Pick a model per request with the model field (default veo-3.1-fast). Veo credit costs come from Google’s official Flow credits table, the omni-flash ones are measured against the live API:

Model id Tier / speed Durations Credits per video
veo-3.1-lite Cheapest Veo 4s, 6s, 8s 10 (Non-Ultra) Β· 5 (Ultra)
veo-3.1-lite-low-priority Lite, lower priority 4s, 6s, 8s 0, plan owner on Ultra $199.99 only
veo-3.1-fast (default) Fast Veo 4s, 6s, 8s 20 (Non-Ultra) Β· 10 (Ultra)
veo-3.1-quality Highest-quality Veo 8s only 100
omni-flash Gemini Omni Flash, audio-native 4s, 6s, 8s, 10s 7 / 10 / 12 / 15 (by length, at 720p)

On Veo, 4s and 6s videos need Ultra (see the overview). Google Flow also makes stills with the Nano Banana models through the separate POST /images endpoint.

Pricing

Each video spends Flow credits from your Google AI subscription, with no per-video charge from useapi.net. The price per video on every plan, against the official API, is in the Google Flow price table, and Veo 3.1 API pricing covers Veo in detail.

Generate a video in two API calls

You need a useapi.net API token and a connected Google Flow account. Video jobs are asynchronous: pass async: true and the create call returns a jobid immediately, then you poll until the video is ready.

  1. Submit the job β€” POST https://api.useapi.net/v1/google-flow/videos:
curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A serene mountain landscape at sunset, camera slowly panning right",
    "model": "veo-3.1-fast",
    "aspectRatio": "landscape",
    "duration": 8,
    "async": true
  }'

In async mode the response returns immediately with 201 Created, a lowercase jobid, and status: "created":

{
  "jobid": "j1731859234567v-u12345-email:jo***@gmail.com-bot:google-flow",
  "type": "video",
  "status": "created",
  "created": "2026-06-15T12:34:56.789Z",
  "request": {
    "async": true,
    "prompt": "A serene mountain landscape at sunset, camera slowly panning right",
    "model": "veo-3.1-fast",
    "aspectRatio": "landscape",
    "duration": 8
  }
}
  1. Poll for the result β€” GET https://api.useapi.net/v1/google-flow/jobs/{jobId}:
curl "https://api.useapi.net/v1/google-flow/jobs/{jobId}" \
  -H "Authorization: Bearer $USEAPI_TOKEN"

status moves through created β†’ started β†’ completed. When it reaches completed, the finished MP4 is in response.media[].videoUrl (a single prompt with count > 1 returns one entry per video):

{
  "jobid": "j1731859234567v-...",
  "type": "video",
  "status": "completed",
  "response": {
    "media": [
      {
        "mediaGenerationId": "user:12345-email:6a6f...-video:a1d95d21-...",
        "videoUrl": "https://flow-content.google/video/a1d95d21-...?Expires=...",
        "thumbnailUrl": "https://flow-content.google/image/a1d95d21-...?Expires=..."
      }
    ],
    "remainingCredits": 18760
  }
}

Veo 3.1 typically finishes in 60–180 seconds depending on model and mode. In our runs on October 20, 2026, nine 8-second videos took 50 to 105 seconds from submit to the finished MP4 (measured runs). The signed videoUrl is valid for about 6 hours, so download promptly. Prefer not to poll? Pass a replyUrl in the create body to receive a webhook callback when the job completes.

Image-to-video and reference-to-video

Veo takes image inputs two different ways. Upload each image first with POST /assets/email (raw bytes, an image Content-Type, PNG/JPEG/WebP up to 20 MB). The upload response nests the id at mediaGenerationId.mediaGenerationId β€” that nested string is what you pass back. When you supply any image input you can omit email: the request routes to the account where the image was uploaded.

First / last frame (I2V)

Your image becomes a literal frame of the video. Pass it as startImage to start from that frame:

curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Brave Captain Cat chasing away the pirates!",
    "model": "veo-3.1-fast",
    "aspectRatio": "portrait",
    "startImage": "user:12345-email:6a6f...-image:ff9aa5cc-...",
    "async": true
  }'

Add endImage (a second uploaded reference id) to pin the last frame so Veo generates the transition between the two. endImage requires startImage β€” end-frame-only is not supported. omni-flash accepts the same pair, but there the two frames are the only input it takes β€” no referenceImage_*, character_*, or referenceAudio_* alongside them.

Reference images (R2V)

Your images guide the whole video β€” subject, character, or style β€” rather than a specific frame. Pass referenceImage_1 through referenceImage_3 (Veo accepts up to 3, on 8-second videos only, and not on veo-3.1-quality). R2V cannot be combined with startImage/endImage:

curl -X POST "https://api.useapi.net/v1/google-flow/videos" \
  -H "Authorization: Bearer $USEAPI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A lady says to the dog, \"Say meow meow meow.\" The dog looks at the lady with a surprised, frustrated look and woofs in a human voice, \"What the heck lady?!\"",
    "model": "veo-3.1-fast",
    "aspectRatio": "portrait",
    "referenceImage_1": "user:12345-email:6a6f...-image:abcde-...",
    "async": true
  }'

Mention each reference inline with an @referenceImage_N marker in the prompt, add a referenceAudio_1 (a system voice name like Zephyr) for narration, or pass a reusable character as character_1 (it drives the same R2V mode and counts toward the 3-reference budget). Omni Flash takes references further β€” up to 7, plus video-to-video editing and synced voices β€” see the Omni Flash tutorial.

Batch-generate with a script

Finding the right shot takes many attempts, and running them by hand is tedious. The Node.js script below reads a list of prompts from prompts.json, uploads any start/end frame images, submits each job in async mode, then polls and downloads every finished MP4 β€” so you can queue a batch and come back to the winners.

You need Node.js v21 or newer. Put prompts.json and google-flow.mjs in the same folder and run node ./google-flow.mjs API_TOKEN EMAIL, where API_TOKEN is your useapi.net API token and EMAIL is your connected Google Flow account email. The script looks the account up by email automatically and checks its health field before submitting.

Prefer to clone and run it locally? The complete google-flow.mjs and prompts.json are on GitHub in useapi/google-flow-api.

Expand prompts.json
[
    {
        "prompt": "By default the veo-3.1-fast model with a landscape 8-second clip will be used."
    },
    {
        "model": "veo-3.1-quality",
        "aspectRatio": "landscape",
        "duration": 8,
        "count": 2,
        "seed": 123456,
        "prompt": "Highest-quality Veo 3.1, two variations. For all parameters see https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos"
    },
    {
        "model": "veo-3.1-fast",
        "aspectRatio": "portrait",
        "duration": 8,
        "startImage": "./first_image.jpeg",
        "endImage": "./last_image.jpeg",
        "prompt": "Image-to-video with a start frame (startImage) and an end frame (endImage). Supported by Veo and by omni-flash."
    },
    {
        "model": "omni-flash",
        "duration": 10,
        "prompt": "Gemini Omni Flash is audio-native and supports 10-second clips. Other models (veo-3.1-lite, veo-3.1-lite-low-priority, …) are selectable via the model field β€” see the POST /videos docs."
    }
]
Expand google-flow.mjs script
/*

Script version 1.1, September 11, 2026

Script to batch-generate videos using prompts with the Google Flow API v1 by useapi.net πŸš€
Uses the POST /videos endpoint in async mode (default model: veo-3.1-fast) and polls GET /jobs/{jobId}.
For more details visit https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos

Installation Instructions:
==========================

You need Node.js v21 or newer installed to run this script. Download and install Node.js from:

- Windows, macOS, Linux: https://nodejs.org/

After installation, verify by running the following command in a terminal:

   node -v

Running the Script:
===================

Usage: node google-flow.mjs <API_TOKEN> <EMAIL> [PROMPTS_FILE]

Replace API_TOKEN with your actual useapi.net API token, see https://useapi.net/docs/start-here/setup-useapi
Replace EMAIL with configured Google Flow email account, see https://useapi.net/docs/start-here/setup-google-flow
If optional PROMPTS_FILE not provided prompts.json will be used.

Example:
--------

node google-flow.mjs user:1234-abcdefhijklmnopqrstuv [email protected]

This command executes the script using API token user:1234-abcdefhijklmnopqrstuv with [email protected] Google Flow account email.

Changelog:
==========

- June 15, 2026: Initial release.
- September 11, 2026: Accepts .jpg and upper-case image extensions for startImage and endImage. A prompt whose startImage or endImage fails to upload is skipped and logged to google-flow_errors.txt instead of being submitted without that frame. Saves a downloaded video under its final name only once it has been fully received, so a failed download leaves no partial MP4 for the next run to skip as "already exists".

*/

import readline from 'node:readline';
import fs from 'fs/promises';
import path from 'path';
import { writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';


// Constants
const RESULTS_FILE = 'google-flow_results.txt';
const ERRORS_FILE = 'google-flow_errors.txt';
const DEFAULT_PROMPTS_FILE = 'prompts.json';
const DEFAULT_MODEL = 'veo-3.1-fast';
const SLEEP_429 = 30 * 1000; // in milliseconds
const SLEEP_POLL = 15 * 1000; // in milliseconds

const urlAccounts = 'https://api.useapi.net/v1/google-flow/accounts';
const urlVideos = 'https://api.useapi.net/v1/google-flow/videos';
const urlJobs = 'https://api.useapi.net/v1/google-flow/jobs/';
const urlUploadAsset = 'https://api.useapi.net/v1/google-flow/assets/';

// To upload .webp keep its .webp extension β€” Google Flow accepts png, jpeg and webp.
const supportedFileExtensions = ['png', 'jpg', 'jpeg', 'webp'];

// { filename: mediaGenerationId }
const uploadedFiles = {};

// Utility to sleep for given milliseconds
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Function to fetch configured Google Flow API accounts
async function fetchAccounts(apiToken) {
    const response = await fetch(urlAccounts, {
        headers: {
            'Accept': 'application/json',
            'Authorization': `Bearer ${apiToken}`
        }
    });

    if (!response.ok) {
        console.error(`β›” Error fetching accounts (HTTP ${response.status}): ${response.statusText}`);
        process.exit(1);
    }

    return response.json();
}

const elapsedTimeSec = (start) => (Date.now() - start) / 1000;

// Map a file extension to the Content-Type required by POST /assets
const contentTypeForExt = (ext) => ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';

async function uploadAsset(apiToken, email, filename) {

    // Check if already uploaded
    if (uploadedFiles.hasOwnProperty(filename))
        return uploadedFiles[filename];

    const startTime = Date.now();

    console.log(`⬆️  Account ${email} uploading file…`, filename);

    const body = new Blob([await fs.readFile(filename)]);

    const fileExt = filename.split('.').pop().toLowerCase();

    const response = await fetch(`${urlUploadAsset}${encodeURIComponent(email)}`, {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Authorization': `Bearer ${apiToken}`,
            'Content-Type': contentTypeForExt(fileExt)
        },
        body
    });

    if (response.ok) {
        const json = await response.json();
        // POST /assets returns the reference id nested as mediaGenerationId.mediaGenerationId
        const mediaGenerationId = json?.mediaGenerationId?.mediaGenerationId;
        console.log(`πŸ†— mediaGenerationId (${elapsedTimeSec(startTime)} sec)`, mediaGenerationId);
        uploadedFiles[filename] = mediaGenerationId;
    }
    else {
        console.error(`❗ Unable to upload file HTTP ${response.status} (${elapsedTimeSec(startTime)} sec)`, await response.text());
        // Do not attempt to upload failed file again
        uploadedFiles[filename] = undefined;
    }

    return uploadedFiles[filename];
}

async function submit(apiToken, url, body, index, prompt) {
    const createResponse = await fetch(url, {
        method: 'POST',
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${apiToken}`
        },
        body
    });

    const createBody = await createResponse.text();

    // Async POST /videos returns 201 Created with a jobid to poll.
    if (createResponse.status == 201) {
        const json = JSON.parse(createBody);
        // 201 async payload uses lowercase "jobid"; sync 200 uses "jobId". Accept either.
        const jobId = json.jobid ?? json.jobId;
        if (jobId) {
            await fs.appendFile(RESULTS_FILE, `${jobId},#${index}:${prompt}\n`);
            console.log(`βœ… jobId`, jobId);
            return 201;
        } else {
            const error = `No jobid found in HTTP 201 response`;
            console.log(`❓ ${error}`, createBody);
            await fs.appendFile(ERRORS_FILE, `${error},#${index}:${prompt}\n`);
            return 500;
        }
    } else {
        switch (createResponse.status) {
            case 429:
                console.log(`πŸ”„οΈ Retry on HTTP ${createResponse.status}`, createBody);
                break;
            case 503:
                console.log(`πŸ”„οΈ Service unavailable, retry on HTTP ${createResponse.status}`, createBody);
                break;
            case 400:
                console.log(`πŸ›‘ Rejected request (validation or content policy)`, createBody);
                await fs.appendFile(ERRORS_FILE, `${createResponse.status},#${index}:${prompt}\n`);
                break;
            case 402:
                console.log(`πŸ›‘ No subscription / insufficient credits`, createBody);
                break;
            default:
                console.log(`❗ FAILED with HTTP ${createResponse.status}`, createBody);
                await fs.appendFile(ERRORS_FILE, `${createResponse.status},#${index}:${prompt}\n`);
        }
        return createResponse.status;
    }
}

// Submit a single prompt to POST /videos in async mode.
// startImage = start frame (I2V), endImage = end frame (I2V-FL, requires startImage).
async function submitVideo(apiToken, email, prompt, index) {
    const { model, prompt: text, startImage, endImage, aspectRatio, duration, count, seed } = prompt;

    const useModel = model ?? DEFAULT_MODEL;

    console.log(`πŸš€ ${useModel} Β» Prompt #${index} β€’ account ${email} …`);

    const startImageId = startImage ? await uploadAsset(apiToken, email, startImage) : undefined;
    const endImageId = endImage ? await uploadAsset(apiToken, email, endImage) : undefined;

    // A frame that failed to upload would be left out of the body, and the prompt would still be
    // generated (and charged) without it. Skip the prompt instead. uploadAsset remembers the failed file,
    // so a later prompt using it is skipped too, without another upload attempt.
    const failedUploads = [['startImage', startImage, startImageId], ['endImage', endImage, endImageId]]
        .filter(([, file, id]) => file && !id);
    for (const [param, file] of failedUploads) {
        console.error(`πŸ›‘ Prompt #${index} skipped: ${param} ${file} failed to upload`);
        await fs.appendFile(ERRORS_FILE, `Upload failed for ${param} ${file},#${index}:${text}\n`);
    }
    if (failedUploads.length)
        return;

    const body = JSON.stringify({
        model: useModel,
        email,
        prompt: text,
        aspectRatio,
        duration,
        count,
        seed,
        startImage: startImageId,
        endImage: endImageId,
        async: true
    });

    return await submit(apiToken, urlVideos, body, index, text);
}

// Function to download videos
async function download(apiToken) {
    if (! await fileExists(RESULTS_FILE)) return;

    try {
        const resultsContent = await fs.readFile(RESULTS_FILE, 'utf8');
        const lines = resultsContent.trim().split('\n');

        for (const line of lines) {
            const [jobId, prompt] = line.split(',');

            console.log(`πŸ‘‰ ${jobId}`);

            while (true) {
                const response = await fetch(`${urlJobs}${jobId}`, {
                    headers: {
                        'Accept': 'application/json',
                        'Authorization': `Bearer ${apiToken}`
                    }
                });

                if (!response.ok) {
                    console.log(`πŸ›‘ Poll failed ${jobId} (HTTP ${response.status}):\n${prompt}\n`, await response.text());
                    break;
                }

                const jobBody = await response.json();
                const { status, error, response: jobResponse } = jobBody;

                if (status == 'failed') {
                    console.error(`πŸ›‘ FAILED ${jobId} (${error}):\n${prompt}\n`);
                    break;
                }

                if (status == 'completed') {
                    const media = jobResponse?.media ?? [];

                    if (media.length == 0)
                        console.error(`πŸ›‘ Completed but no media for ${jobId}:\n${prompt}\n`);

                    // A single prompt with count > 1 returns multiple videos.
                    for (let i = 0; i < media.length; i++) {
                        const url = media[i]?.videoUrl;
                        const videoFilename = `${jobId.replace(/[:*]/g, '_')}_${i + 1}.mp4`;

                        try {
                            await fs.access(videoFilename);
                            console.log(`⚠️ ${videoFilename} already exists. Skipping download.`);
                            continue;
                        } catch {
                            // File does not exist, proceed with downloading
                        }

                        if (url) {
                            console.log(`βœ… Downloading ${url} to ${videoFilename}`);
                            // Stream into a temporary file and rename it once the whole video has arrived, so a download
                            // that drops mid-stream leaves no partial file behind for the next run to skip as "already exists".
                            const tempFilename = `${videoFilename}.part`;
                            try {
                                const videoResponse = await fetch(url);
                                if (!videoResponse.ok) {
                                    console.error(`β›” Unable to download ${jobId} (HTTP ${videoResponse.status}):\n${prompt}\n`, url);
                                    continue;
                                }
                                const stream = Readable.fromWeb(videoResponse.body);
                                await writeFile(tempFilename, stream);
                                await fs.rename(tempFilename, videoFilename);
                            } catch (err) {
                                await fs.rm(tempFilename, { force: true });
                                console.error(`β›” Error during download: ${err}`);
                            }
                        } else
                            console.error(`πŸ›‘ No videoUrl for ${jobId} media #${i + 1}:\n${prompt}\n`);
                    }

                    break;
                }

                console.log(`βŒ› ${jobId} status (${status}) and is still in progress, waiting…`);
                await sleep(SLEEP_POLL);
            }
        }
    } catch (error) {
        console.log(`β›” Error during download:`, error.stack || error);
    }
}

// Main function
async function main() {
    const apiToken = process.argv[2];
    const email = process.argv[3];
    const promptFile = process.argv[4] || DEFAULT_PROMPTS_FILE;

    if (!apiToken || !email) {
        console.error('Usage: node google-flow.mjs <API_TOKEN> <EMAIL> [PROMPTS_FILE]');
        process.exit(1);
    }

    console.info('Script v1.1');

    console.info('Node version is: ' + process.version);

    try {
        if (await fileExists(RESULTS_FILE)) {
            let user_input;
            while (!['y', 'n'].includes(user_input)) {
                user_input = (await promptUser(`❔ ${RESULTS_FILE} file detected. Do you want to download the results now? (y/n): `))?.toLowerCase();
                if (user_input == 'y') {
                    await download(apiToken);
                    await fs.unlink(RESULTS_FILE);
                }
            }
        }

        const start = new Date();
        try {
            console.info('START EXECUTION', start);
            await execute(apiToken, email, promptFile); // Pass the promptFile to execute function
        }
        finally {
            console.info('COMPLETED', new Date());
            console.info('EXECUTION ELAPSED', diffInMinutesAndSeconds(start, new Date()));
        }

        try {
            console.info('START DOWNLOAD', start);
            await download(apiToken);
        }
        finally {
            console.info('TOTAL ELAPSED', diffInMinutesAndSeconds(start, new Date()));
        }
    } catch (error) {
        console.error('β›” Error during execution:', error.stack || error);
    }
}

// Modify the execute function to accept promptFile as a parameter
async function execute(apiToken, email, promptFile) {
    const accounts = await fetchAccounts(apiToken);

    console.info(`Configured Google Flow API accounts (${Object.keys(accounts).length}):`, Object.keys(accounts).join(', '));

    if (Object.keys(accounts).length <= 0) {
        console.error(`β›” No configured Google Flow accounts found. Please refer to https://useapi.net/docs/start-here/setup-google-flow`);
        process.exit(1);
    }

    if (!accounts[email]) {
        console.error(`β›” Account ${email} not found. Please refer to https://useapi.net/docs/start-here/setup-google-flow`);
        process.exit(1);
    }

    if (accounts[email].health && accounts[email].health !== 'OK') {
        console.error(`β›” Account ${email} health is '${accounts[email].health}'. Please resolve and update the account, see https://useapi.net/docs/start-here/setup-google-flow`);
        process.exit(1);
    }

    const promptData = await fs.readFile(promptFile, 'utf8');
    const prompts = JSON.parse(promptData);
    console.log(`Total number of prompts to process`, prompts.length);

    let warnings = [];

    // Parameters accepted by this script for the POST /videos endpoint.
    // See https://useapi.net/docs/api-google-flow-v1/post-google-flow-videos for every model's full parameter set.
    const supportedParams = ['model', 'prompt', 'startImage', 'endImage', 'aspectRatio', 'duration', 'count', 'seed'];

    const invalidKeys = (prompt) => Object.keys(prompt).filter(key => !key.startsWith('__') && !supportedParams.includes(key))

    for (let i = 1; i <= prompts.length; i++) {
        const prompt = prompts[i - 1];
        const { prompt: text, startImage, endImage } = prompt;

        const validateImage = async (file) => {
            if (file) {
                try {
                    await fs.access(file);
                } catch {
                    warnings.push(`⚠️  Image '${file}' does not exist. Prompt ${i}`);
                }

                const ext = file.split('.').pop().toLowerCase();

                if (!supportedFileExtensions.includes(ext))
                    warnings.push(`⚠️  Image ${file} extension ${ext} not supported. Prompt ${i}`);
            }
        };

        const notSupported = invalidKeys(prompt);
        if (notSupported.length)
            warnings.push(`⚠️  Following params not supported: ${notSupported.join(',')}. Prompt ${i}`);

        if (!text)
            warnings.push(`⚠️  prompt is required. Prompt ${i}`);

        if (endImage && !startImage)
            warnings.push(`⚠️  endImage requires startImage (end-frame-only is not supported). Prompt ${i}`);

        await Promise.all([validateImage(startImage), validateImage(endImage)]);
    }

    if (warnings.length > 0) {
        warnings.forEach(warning => console.warn(warning));
        console.error(`β›” Execution stopped due to warnings.`);
        process.exit(1);
    }

    for (let i = 0; i < prompts.length; i++) {
        const prompt = prompts[i];
        while (true) {
            const responseCode = await submitVideo(apiToken, email, prompt, i + 1);
            if (responseCode == 429 || responseCode == 503)
                await sleep(SLEEP_429);
            else
                if (responseCode == 402) {
                    process.exit(1);
                } else
                    break;
        }
    }
}

// Utility function to check if a file exists
async function fileExists(path) {
    try {
        await fs.access(path);
        return true;
    } catch {
        return false;
    }
}

// Function to prompt user input
async function promptUser(query) {
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });

    return new Promise((resolve) => rl.question(query, answer => {
        rl.close();
        resolve(answer);
    }));
}

function diffInMinutesAndSeconds(date1, date2) {
    const diffInSeconds = Math.floor((date2 - date1) / 1000);
    return `${Math.floor(diffInSeconds / 60)} minutes ${diffInSeconds % 60} seconds`;
};

main();

Examples

Real Veo 3.1 videos made through this Google Flow API, taken straight from our release notes.

veo-3.1-fast Β» Brave Captain Cat chasing away the pirates (image-to-video)

From Google Flow API v1: Initial Release. Animated from a Nano Banana still passed as startImage.

veo-3.1-fast Β» Lady and her dog, with AI voice narration

From Veo 3.1 Lite and Voice Narration. Reference-to-video with the referenceAudio_1: "Zephyr" preset adding spoken dialogue.

veo-3.1-fast Β» Dancing cha-cha (portrait reference-to-video)

From Veo 3.1 Portrait R2V. Portrait R2V from a single reference image.

Frequently asked questions

  • Is there a Veo 3.1 API? Through useapi.net, yes: the Google Flow API runs Veo 3.1 Quality, Fast and Lite over REST on your Google Flow website subscription (Google AI Pro or Ultra). An 8-second Veo 3.1 Quality video costs $0.80 on Google AI Ultra $199.99, against $3.20 on the official Veo API (as of October 2026).
  • What’s the difference between Veo 3.1 Quality, Fast, and Lite? Through useapi.net you pick one per request with the model field. veo-3.1-quality gives the highest fidelity, veo-3.1-fast is the default balance of speed and quality, and veo-3.1-lite is the cheapest: $0.04 a video on Google AI Ultra $199.99, against $0.40 on the official Veo API. On that plan, veo-3.1-lite-low-priority costs $0, about 1,000 or more videos a day (as of October 2026).
  • Can I do image-to-video with Veo? Through useapi.net, yes. Upload your start frame with POST /assets/email, then pass the returned reference id (nested at mediaGenerationId.mediaGenerationId) as startImage, and optionally endImage to pin the last frame (it requires startImage). To guide the subject or style instead of a frame, pass up to three referenceImage_N. See Image-to-video above.
  • Do I need to solve a captcha? Through useapi.net you never solve one yourself. Google Flow asks for a reCAPTCHA on every video, and the useapi.net worker solves it automatically: your first Google Flow account comes with 300 free solves on our own AntiCaptcha, CapSolver and EzCaptcha keys, and after that you add your own provider keys with POST /accounts/captcha-providers. The script in this guide sends no captchaToken.
  • Do I need a Veo 3.1 API key? Through useapi.net you need no Google API key and no Google Cloud project: one useapi.net API token calls Veo 3.1, and you connect your Google account once through the automated setup. Google Flow itself has no public API, and once the Gemini API’s Veo 3.1 Preview models shut down on October 22, 2026, Google’s own Veo API needs a Cloud project with billing.

Conclusion

Visit our Discord Server or Telegram Channel for any support questions and concerns.

Check our GitHub repo with code examples.

Cross posted

Also published on dev.to.