Calling Gemini Omni from Your App: A Developer's Guide
The Gemini Omni REST API is available on Pro and Premium plans. It lets you submit generation jobs, poll for results, and receive webhook notifications all from your own application without touching the browser Playground. This tutorial walks you through the full integration: authentication, generating a video, handling the async result, and dealing with errors.
If you prefer to follow along in the browser first, the Playground is the fastest way to understand what you’re building before you write any code.
Prerequisites
- A Pro or Premium Gemini Omni account
- An API key (from /api-keys)
- Basic familiarity with REST APIs and async patterns
1. Get your API key
API keys are created in the /api-keys dashboard page. They are only shown once at creation time copy it immediately. The format is gomni_ followed by 64 hex characters.
Store your key securely. Never commit it to version control. Use environment variables or a secrets manager:
# .env
GEMINI_OMNI_API_KEY=gomni_your_key_here
All requests are authenticated with a Bearer token in the Authorization header:
Authorization: Bearer gomni_your_key_here
2. Submit a generation job
All generation types use the same endpoint: POST https://googlegeminiomni.com/api/v1/generate
Text-to-video
curl -X POST https://googlegeminiomni.com/api/v1/generate \
-H "Authorization: Bearer $GEMINI_OMNI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-omni-video",
"prompt": "A lone lighthouse on a rocky cliff, storm waves crashing below, dark clouds, cinematic 4K wide shot, slow push forward",
"resolution": "1080p",
"duration": 8
}'
The API returns immediately with a job ID and accepted status:
{
"jobId": "job_abc123",
"status": "accepted",
"creditCost": 150,
"estimatedSeconds": 60
}
Image-to-video
Supply the first frame as imageUrl. The model animates from that image:
{
"model": "gemini-omni-video",
"prompt": "The lighthouse beam sweeping through the storm, waves intensifying",
"imageUrl": "https://your-cdn.com/lighthouse.jpg",
"resolution": "1080p",
"duration": 8
}
Character video
Upload a reference photo first via POST /api/v1/upload, then reference the returned URL:
{
"model": "gemini-omni-character",
"prompt": "The character presenting to camera in a modern boardroom, confident and professional",
"referenceUrl": "https://your-cdn.com/reference-face.jpg",
"resolution": "1080p",
"duration": 8
}
AI voice (text-to-speech)
{
"model": "gemini-omni-audio",
"script": "Welcome to your daily briefing. Here are the top three priorities for today.",
"language": "en-US",
"voiceProfile": "professional-male"
}
Lip-sync
Attach a character video to a voice job for synchronized lip movement:
{
"model": "gemini-omni-audio",
"script": "Welcome to your daily briefing.",
"language": "en-US",
"voiceProfile": "professional-male",
"videoUrl": "https://your-cdn.com/character-clip.mp4"
}
3. Poll for results
Generation is asynchronous. After submitting, poll GET /api/v1/jobs/{jobId} until the status is done or failed.
async function pollJob(jobId, apiKey) {
const maxAttempts = 60;
const intervalMs = 3000;
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(`https://googlegeminiomni.com/api/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const job = await res.json();
if (job.status === 'done') {
return job.outputUrl;
}
if (job.status === 'failed') {
throw new Error(`Job failed: ${job.error}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Job timed out after 3 minutes');
}
Status values:
acceptedreceived, queuedgeneratingmodel is runningdonecomplete,outputUrlis availablefailedmodel error, no credits consumed
outputUrl is a signed R2 URL valid for 7 days. Download the file to your own storage before it expires.
4. Use webhooks instead of polling (recommended for production)
Polling works but wastes requests. In production, use webhooks: register a callback URL on the job submission, and the platform will POST to it when the job completes.
Register a callback URL
{
"model": "gemini-omni-video",
"prompt": "...",
"resolution": "1080p",
"duration": 8,
"callbackUrl": "https://your-app.com/webhooks/gemini-omni"
}
Webhook payload
{
"event": "job.completed",
"jobId": "job_abc123",
"status": "done",
"outputUrl": "https://r2.googlegeminiomni.com/omni/usr_xyz/job_abc123/output.mp4",
"creditCost": 150,
"model": "gemini-omni-video",
"timestamp": "2026-05-15T14:23:00Z"
}
Failed jobs send:
{
"event": "job.failed",
"jobId": "job_abc123",
"status": "failed",
"error": "Content policy: prompt contains restricted subject matter",
"creditCost": 0,
"timestamp": "2026-05-15T14:23:00Z"
}
Verify the webhook signature
Every webhook request includes an X-Gemini-Signature header an HMAC-SHA256 of the raw request body, keyed with your webhook secret.
import crypto from 'crypto';
function verifyWebhook(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// In your webhook handler (Express example):
app.post('/webhooks/gemini-omni', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-gemini-signature'];
const secret = process.env.GEMINI_OMNI_WEBHOOK_SECRET;
if (!verifyWebhook(req.body, sig, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body.toString());
if (payload.event === 'job.completed') {
// download payload.outputUrl and store in your own storage
queueDownload(payload.jobId, payload.outputUrl);
}
res.status(200).json({ received: true });
});
Always return 200 quickly. If your handler takes too long, the platform will retry (3 retries: 30 seconds, 5 minutes, 30 minutes). Offload actual processing to a queue.
5. Handle errors
The API uses standard HTTP status codes. Your integration should handle:
| Code | Meaning | Action |
|---|---|---|
| 400 | Invalid request bad prompt, unknown model, missing field | Fix the request body |
| 401 | Invalid or missing API key | Check key, check Authorization header format |
| 402 | Insufficient credits | Top up credits or upgrade plan |
| 403 | Action not permitted API not available on Starter plan | Upgrade to Pro/Premium |
| 404 | Job not found | Check job ID |
| 429 | Rate limit exceeded | Back off and retry (see below) |
| 500 | Internal server error | Retry with exponential backoff |
| 503 | Service unavailable | Retry after delay |
Rate limits by plan
| Plan | Requests/minute | Requests/hour | Concurrent jobs |
|---|---|---|---|
| Pro | 30 | 200 | 5 |
| Premium | 60 | 500 | 10 |
Retry logic
async function submitWithRetry(payload, apiKey, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch('https://googlegeminiomni.com/api/v1/generate', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
const status = res.status;
// Don't retry client errors (except 429)
if (status >= 400 && status < 500 && status !== 429) {
throw new Error(`Client error ${status}: ${await res.text()}`);
}
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error('Max retries exceeded');
}
6. Fan out to CDN
outputUrl is a time-limited signed URL. For production, download and re-host immediately:
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
async function ingestToYourCDN(jobId, outputUrl) {
const response = await fetch(outputUrl);
const buffer = await response.arrayBuffer();
const s3 = new S3Client({ region: 'us-east-1' });
await s3.send(new PutObjectCommand({
Bucket: process.env.YOUR_BUCKET,
Key: `videos/${jobId}.mp4`,
Body: Buffer.from(buffer),
ContentType: 'video/mp4',
}));
return `https://cdn.yourapp.com/videos/${jobId}.mp4`;
}
7. Full end-to-end example (Node.js)
import 'dotenv/config';
const API_KEY = process.env.GEMINI_OMNI_API_KEY;
const BASE_URL = 'https://googlegeminiomni.com';
async function generateVideo(prompt, resolution = '1080p', duration = 8) {
// Submit
const submitRes = await fetch(`${BASE_URL}/api/v1/generate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'gemini-omni-video', prompt, resolution, duration }),
});
if (!submitRes.ok) {
throw new Error(`Submit failed: ${await submitRes.text()}`);
}
const { jobId } = await submitRes.json();
console.log(`Job submitted: ${jobId}`);
// Poll
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 3000));
const pollRes = await fetch(`${BASE_URL}/api/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const job = await pollRes.json();
console.log(`Status: ${job.status}`);
if (job.status === 'done') {
console.log(`Output: ${job.outputUrl}`);
return job.outputUrl;
}
if (job.status === 'failed') {
throw new Error(`Job failed: ${job.error}`);
}
}
throw new Error('Timeout');
}
// Example usage
generateVideo(
'A lone lighthouse on a rocky cliff, storm waves crashing below, cinematic 4K wide shot, slow push forward',
'1080p',
8
)
.then((url) => console.log('Done:', url))
.catch(console.error);
8. Bulk generation pattern
For batch workloads generating 50+ clips with different prompts use a queue to respect rate limits:
async function bulkGenerate(prompts, apiKey, concurrency = 5) {
const results = [];
const queue = [...prompts];
const inFlight = new Set();
while (queue.length > 0 || inFlight.size > 0) {
while (queue.length > 0 && inFlight.size < concurrency) {
const prompt = queue.shift();
const promise = generateVideo(prompt, '1080p', 8)
.then((url) => results.push({ prompt, url, status: 'done' }))
.catch((err) => results.push({ prompt, error: err.message, status: 'failed' }))
.finally(() => inFlight.delete(promise));
inFlight.add(promise);
}
if (inFlight.size > 0) {
await Promise.race(inFlight);
}
}
return results;
}
This respects the 5-concurrent-job limit on Pro. Adjust concurrency to match your plan.
API reference
Full documentation is in the developer docs:
Try it in the Playground first
If you’re new to the API, the best way to understand what prompts produce what output is to experiment in the Playground before writing integration code. The Playground uses the same models, same resolution settings, and same credit costs it’s the same API under the hood.
When your prompts are dialed in from Playground testing, moving them to an API integration is straightforward.
Related:
Ready to generate your first video?
Try the Playground no configuration required.