How to Generate AI Video with Veo 3.1 via the Google Flow API

6 min read β€’ June 15, 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, Google’s flagship video model, runs from a single curl through useapi.net. You drive your own Google Flow account β€” no Google Cloud project, no enterprise approval, and up to several times lower cost per generation than the official metered API.

The official Gemini API and Vertex meter Veo per second of output. This third-party Google Flow API runs on a flat subscription instead β€” you spend your normal Flow credits plus one monthly fee for API access, far cheaper per clip. Below: the models you get, the price gap versus the official API, then the two-call workflow and a runnable batch script.

Supported models

Pick a model per request with the model field (default veo-3.1-fast). Credit costs come from Google’s official Flow credits table:

Model id Tier / speed Durations Credits per generation
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 β€” Ultra $199 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 15 / 20 / 25 / 30 (by length)

Notes from the overview: 4s/6s are Ultra-only on Veo, veo-3.1-quality is 8s only, veo-3.1-lite-low-priority costs 0 credits but is available only to Google AI Ultra $199 subscribers, and 10s clips are omni-flash only. Google Flow can also generate stills with Imagen 4 and the Nano Banana models via the separate POST /images endpoint.

Pricing

You spend your Google AI plan’s Flow credits as usual, plus a flat $15/month to useapi.net for API access to every supported service β€” no per-call surcharge.

Third-party Google Flow API by useapi.net vs. the official Gemini API β€” drive your own Google Flow subscription instead of metered, per-call API billing:

Model Official Gemini API useapi.net (Flow Pro) useapi.net (Flow Ultra)
Veo 3.1 Fast β€” 8s clip $0.80 ~$0.40 ~$0.10
Veo 3.1 Quality β€” 8s clip $3.20 ~$2.00 ~$1.00
Veo 3.1 Lite β€” 8s clip $0.40 ~$0.20 ~$0.05
Veo 3.1 Lite, lower priority β€” β€” $0 (Ultra $199)
Gemini Omni Flash β€” 8s clip β€” (Flow only) ~$0.50 ~$0.25
Nano Banana Pro β€” per image $0.134 included included
Nano Banana β€” per image $0.039 included included
Imagen 4 β€” per image metered included included

Expected daily output β€” Ultra ($199/mo) plan. Daily averages observed on top real accounts. These are expected, not guaranteed: Google governs the underlying Flow allowances and they vary with demand.

Free workload (no Flow credits spent) Avg generations / day*
Veo 3.1 Lite β€” lower priority video ~1,000
Images β€” Nano Banana, Nano Banana Pro, Imagen 4 up to ~500

*Averages from the busiest real accounts β€” not guarantees. They are set by Google’s own allowances and fluctuate with demand. Applies only to the free lower-priority video queue and image generation. Credit-metered models (Veo 3.1 Fast / Quality and Gemini Omni Flash) are not shown here β€” their volume is bounded by your plan’s monthly Flow credits (see the credit table), not a free allowance.

Veo is billed per second on the Gemini API (an 8-second 720p clip shown) and images are priced per image. Through useapi.net you spend your own Flow plan credits and pay a flat $15/month β€” image generation is included on any Google AI plan, and there is no Google Cloud project, API key, or per-call metering.

Full per-tier credit allowances and the per-model cost breakdown live on the Google Flow overview. See the setup guide to connect your account.

Generate a video in two API calls

You need a useapi.net API token and a connected Google Flow account. Generation is 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 clip):

{
  "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. The signed videoUrl is valid for about 24 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 clip. 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. On omni-flash, only startImage is accepted.

Reference images (R2V)

Your images guide the whole clip β€” subject, character, or style β€” rather than a specific frame. Pass referenceImage_1 through referenceImage_3 (Veo accepts up to 3, on 8-second clips 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). Veo only β€” omni-flash supports a start frame only."
    },
    {
        "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.0, June 15, 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.

*/

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', '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();

    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, Veo only, 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;

    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}`);
                            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(videoFilename, stream);
                            } catch (err) {
                                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.0');

    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();

                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 clips generated 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. A reference-to-video generation 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? Yes β€” two ways. Google offers Veo 3.1 directly through the official Gemini API (and Vertex AI), billed per second on a Google Cloud project. Or use useapi.net’s Google Flow API, which gives you programmatic access to Veo 3.1 (Quality, Fast, Lite) by driving your own Google Flow account through a standard REST endpoint β€” no Cloud project, and you pay your flat Flow subscription instead of metered per-second rates (often cheaper per clip β€” see Pricing).
  • What’s the difference between Veo 3.1 Quality, Fast, and Lite? They trade quality for speed and cost: veo-3.1-quality is the highest-fidelity model, veo-3.1-fast is the default balance, and veo-3.1-lite is the cheapest, with veo-3.1-lite-low-priority available to Ultra subscribers at lower scheduling priority. See Supported models above for per-model credits and durations.
  • Can I do image-to-video with Veo? Yes. Upload your start frame with POST /assets/email, then pass the returned reference id (nested at mediaGenerationId.mediaGenerationId) as startImage β€” and optionally endImage for a firstβ†’last transition (Veo only, requires startImage). See Image-to-video above.
  • Do I need to solve a captcha? Google Flow video generation requires reCAPTCHA, but you do not solve it yourself. The useapi.net worker solves the captcha automatically β€” your first Google Flow account comes with 100 free captcha credits as a one-time grant (powered by CapSolver), and after that you configure your own provider keys via POST /accounts/captcha-providers. The script in this guide sends no captchaToken and relies on this automatic solving.
  • How much does it cost? You keep your paid Google AI subscription (needed for video) and add a flat useapi.net subscription for API access to all services. Each generation then spends your normal Flow credits from your Google plan β€” far cheaper than the official metered API; see Pricing above.
  • How is this different from the official Google / Gemini API? Google’s official Veo 3.1 access runs through the Gemini API / Vertex AI on a Google Cloud project, billed per second. Google Flow β€” the consumer app β€” has no public API of its own. useapi.net bridges that gap by driving your own consumer Flow account at its flat subscription price β€” no Cloud project or per-second metering (see the side-by-side Pricing comparison above), and the same subscription covers other AI services too.

Conclusion

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

We regularly post guides and tutorials on the YouTube Channel.

Check our GitHub repo with code examples.

Cross posted