Cómo usar la API de Veo 3.1: generar video con curl mediante la Google Flow API

6 min de lectura • 15 de junio de 2026 (10 de noviembre de 2026)

useapi.net es una API de terceros. No está afiliada a Google ni cuenta con su respaldo.

Índice

  1. Introducción
  2. Modelos compatibles
  3. Precios
  4. Genera un video con dos llamadas a la API
  5. Imagen a video y referencia a video
    1. Primer / último fotograma (I2V)
    2. Imágenes de referencia (R2V)
  6. Genera por lotes con un script
  7. Ejemplos
  8. Preguntas frecuentes
  9. Conclusión

Introducción

Veo 3.1 se ejecuta con un solo curl a través de la Google Flow API de useapi.net, con tu suscripción al sitio web de Google Flow (Google AI Pro o Ultra). Un video Veo 3.1 Fast de 8 segundos cuesta US$0,08 en Google AI Ultra US$199,99, frente a US$0,80 en la API oficial de Veo (a noviembre de 2026).

Google cerró los modelos Veo 3.1 Preview de la Gemini API el 22 de octubre de 2026 y llevó a los desarrolladores a su Agent Platform de Cloud, que cobra las mismas tarifas por segundo en un proyecto de Google Cloud. Esta API de terceros no necesita proyecto de Cloud, ni clave de API de Google, ni aprobación empresarial. A continuación: los modelos, el flujo de dos llamadas, las entradas de imagen y un script por lotes listo para ejecutar.

Modelos compatibles

Elige el modelo en cada solicitud con el campo model (por defecto veo-3.1-fast). Los costos en créditos de Veo vienen de la tabla oficial de créditos de Flow de Google, y los de Omni se midieron con la API en vivo:

Id del modelo Nivel / velocidad Duraciones Créditos por video
veo-3.1-lite El Veo más barato 4s, 6s, 8s 10 (fuera de Ultra) · 5 (Ultra)
veo-3.1-lite-low-priority Lite, prioridad baja 4s, 6s, 8s 0, solo el titular de Ultra US$199,99
veo-3.1-fast (por defecto) Veo rápido 4s, 6s, 8s 20 (fuera de Ultra) · 10 (Ultra)
veo-3.1-quality Veo de máxima calidad solo 8s 100
omni-flash-1.2 Omni 1.2 Flash, audio nativo 3s a 15s 3 por segundo hasta 10s, 6 por segundo después (en 720p)
omni-flash Omni 1.1 Flash, audio nativo 4s, 6s, 8s, 10s 7 / 10 / 12 / 15 (según la duración, en 720p)

En Veo, los videos de 4s y 6s requieren Ultra (consulta la descripción general). Google Flow también genera imágenes fijas con los modelos Nano Banana mediante el endpoint aparte POST /images.

Precios

Cada video gasta créditos de Flow de tu suscripción de Google AI, y useapi.net no cobra nada por video. El precio por video en cada plan, frente a la API oficial, está en la tabla de precios de Google Flow, y el artículo Veo 3.1 API pricing (en inglés) cubre Veo en detalle.

Genera un video con dos llamadas a la API

Necesitas un token de API de useapi.net y una cuenta de Google Flow conectada. Los jobs de video son asíncronos: pasa async: true y la llamada de creación devuelve un jobid al instante. Luego consultas el job hasta que el video esté listo.

  1. Envía el 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
  }'

En modo asíncrono la respuesta llega al instante con 201 Created, un jobid en minúsculas y 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. Consulta el resultado — 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 pasa por created → started → completed. Cuando llega a completed, el MP4 terminado está en response.media[].videoUrl (un solo prompt con count > 1 devuelve una entrada por 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 suele terminar en 60 a 180 segundos, según el modelo y el modo. En nuestras pruebas del 20 de octubre de 2026, nueve videos de 8 segundos tardaron de 50 a 105 segundos desde el envío hasta el MP4 terminado (ejecuciones medidas, en inglés). El videoUrl firmado es válido durante unas 6 horas, así que descárgalo pronto. ¿Prefieres no consultar? Pasa un replyUrl en el cuerpo de la creación para recibir un callback por webhook cuando el job termine.

Imagen a video y referencia a video

Veo acepta imágenes de entrada de dos formas distintas. Primero sube cada imagen con POST /assets/email (bytes sin procesar, un Content-Type de imagen, PNG/JPEG/WebP de hasta 20 MB). La respuesta de la subida trae el id anidado en mediaGenerationId.mediaGenerationId, y esa cadena anidada es la que devuelves en la solicitud. Cuando envías cualquier imagen de entrada puedes omitir email: la solicitud va a la cuenta donde se subió la imagen.

Primer / último fotograma (I2V)

Tu imagen se convierte en un fotograma literal del video. Pásala como startImage para empezar desde ese fotograma:

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
  }'

Agrega endImage (un segundo id de referencia subido) para fijar el último fotograma, y Veo genera la transición entre los dos. endImage requiere startImage: no se admite solo el fotograma final. Los modelos Omni (omni-flash-1.2, omni-flash) aceptan el mismo par, pero ahí los dos fotogramas son la única entrada que admiten: nada de referenceImage_*, character_* ni referenceAudio_* junto con ellos.

Imágenes de referencia (R2V)

Tus imágenes guían todo el video (sujeto, personaje o estilo) en lugar de un fotograma concreto. Pasa de referenceImage_1 a referenceImage_3 (Veo acepta hasta 3, solo en videos de 8 segundos y no en veo-3.1-quality). R2V no se puede combinar con 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
  }'

Menciona cada referencia dentro del prompt con un marcador @referenceImage_N, agrega un referenceAudio_1 (el nombre de una voz del sistema, como Zephyr) para la narración, o pasa un personaje reutilizable como character_1 (usa el mismo modo R2V y cuenta dentro del límite de 3 referencias). Omni 1.2 Flash lleva las referencias más lejos, con hasta 10, además de edición de video a video y voces sincronizadas: consulta el tutorial de Omni Flash.

Genera por lotes con un script

Encontrar la toma correcta lleva muchos intentos, y lanzarlos a mano es tedioso. El script de Node.js de abajo lee una lista de prompts de prompts.json, sube las imágenes de fotograma inicial y final, envía cada job en modo asíncrono y después consulta y descarga cada MP4 terminado. Así puedes poner un lote en cola y volver después por los mejores.

Necesitas Node.js v21 o posterior. Pon prompts.json y google-flow.mjs en la misma carpeta y ejecuta node ./google-flow.mjs API_TOKEN EMAIL, donde API_TOKEN es tu token de API de useapi.net y EMAIL es el correo de tu cuenta de Google Flow conectada. El script busca la cuenta por correo automáticamente y revisa su campo health antes de enviar.

¿Prefieres clonarlo y ejecutarlo en tu equipo? El google-flow.mjs y el prompts.json completos están en GitHub, en useapi/google-flow-api.

Abrir 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,
        "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 both Omni models."
    },
    {
        "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."
    }
]
Abrir el script google-flow.mjs
/*

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 } = 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,
        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'];

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

Ejemplos

Videos reales de Veo 3.1 hechos con esta Google Flow API, tomados directamente de nuestras notas de lanzamiento.

veo-3.1-fast » El valiente Capitán Gato ahuyenta a los piratas (imagen a video)

De Google Flow API v1: Initial Release (en inglés). Animado a partir de una imagen fija de Nano Banana pasada como startImage.

veo-3.1-fast » Una mujer y su perro, con narración de voz por IA

De Veo 3.1 Lite and Voice Narration (en inglés). Referencia a video con el preset referenceAudio_1: "Zephyr", que agrega diálogo hablado.

veo-3.1-fast » Bailando chachachá (referencia a video en vertical)

De Veo 3.1 Portrait R2V (en inglés). R2V en vertical a partir de una sola imagen de referencia.

Preguntas frecuentes

  • ¿Existe una API de Veo 3.1? Con useapi.net, sí: la Google Flow API ejecuta Veo 3.1 Quality, Fast y Lite por REST con tu suscripción al sitio web de Google Flow (Google AI Pro o Ultra). Un video Veo 3.1 Quality de 8 segundos cuesta US$0,80 en Google AI Ultra US$199,99, frente a US$3,20 en la API oficial de Veo (a noviembre de 2026).
  • ¿Qué diferencia hay entre Veo 3.1 Quality, Fast y Lite? Con useapi.net eliges uno en cada solicitud con el campo model. veo-3.1-quality da la mayor fidelidad, veo-3.1-fast es el equilibrio por defecto entre velocidad y calidad, y veo-3.1-lite es el más barato: US$0,04 por video en Google AI Ultra US$199,99, frente a US$0,40 en la API oficial de Veo. En ese plan, veo-3.1-lite-low-priority cuesta US$0, unos 1.000 videos o más al día (a noviembre de 2026).
  • ¿Puedo hacer imagen a video con Veo? Con useapi.net, sí. Sube tu fotograma inicial con POST /assets/email y pasa el id de referencia que devuelve (anidado en mediaGenerationId.mediaGenerationId) como startImage, y si quieres endImage para fijar el último fotograma (requiere startImage). Para guiar el sujeto o el estilo en lugar de un fotograma, pasa hasta tres referenceImage_N. Consulta Imagen a video más arriba.
  • ¿Tengo que resolver un captcha? Con useapi.net nunca resuelves uno tú. Google Flow pide un reCAPTCHA en cada video, y el worker de useapi.net lo resuelve automáticamente: tu primera cuenta de Google Flow incluye 300 resoluciones gratis con nuestras propias claves de AntiCaptcha, CapSolver y EzCaptcha, y después agregas las claves de tu propio proveedor con POST /accounts/captcha-providers. El script de esta guía no envía captchaToken.
  • ¿Necesito una clave de API de Veo 3.1? Con useapi.net no necesitas clave de API de Google ni proyecto de Google Cloud: un solo token de API de useapi.net llama a Veo 3.1, y conectas tu cuenta de Google una vez con la configuración automática. Google Flow en sí no tiene API pública, y desde que los modelos Veo 3.1 Preview de la Gemini API cerraron el 22 de octubre de 2026, la API de Veo del propio Google exige un proyecto de Cloud con facturación.

Conclusión

Visita nuestro servidor de Discord o nuestro canal de Telegram para cualquier pregunta de soporte.

Consulta nuestro repositorio de GitHub con ejemplos de código.

Publicado también en

También publicado en dev.to (en inglés).

Empieza a usar la API

Una sola suscripción de US$15 al mes cubre todas las API de useapi.net: un token para todos los servicios. Conectas hasta 3 cuentas propias por servicio, cada una con la suscripción de su sitio web, y useapi.net se encarga de la API REST, el balanceo de carga y el polling. ¿Necesitas más cuentas? Cada suscripción adicional cuesta US$10 al mes y suma 3 cuentas más por servicio.

Suscríbete — US$15/mes Pagar con cripto

¿No tienes una tarjeta de crédito internacional? También puedes suscribirte con criptomonedas (los pagos con cripto no se reembolsan). Los precios y los planes por volumen están en la página de suscripción. Si pagas con Stripe, recibes el reembolso completo dentro de los 14 días posteriores a la primera compra, si tienes menos de 50 generaciones exitosas. ¿Ya tienes una suscripción? Cambia la cantidad en el portal de clientes de Stripe en lugar de volver a comprar.