Integration7 min read

Integrating Veo into a Postgres-Backed Render Farm

One generations table, one JSONB column, a GIN index, and a unique key on request_id. That is the whole schema your Veo render farm needs to stay honest about spend and failures.


If you run Veo at volume, you need a source of truth that is not the fal dashboard. The dashboard is fine for a quick look. It cannot answer "how much went to 4K renders in Q1" or "which jobs have been pending more than an hour."

This schema has held up across several render farms on fal-ai/veo3.1/text-to-video. The same shape will work for Veo 4, since inputs is JSONB and does not care about new parameter names.

Generations table schema
Generations table schema

The schema

One table. Resist the urge to normalize inputs into columns.

SQL
1CREATE TABLE generations (
2 id BIGSERIAL PRIMARY KEY,
3 request_id TEXT NOT NULL,
4 endpoint TEXT NOT NULL,
5 status TEXT NOT NULL DEFAULT 'submitted',
6 inputs JSONB NOT NULL,
7 result_url TEXT,
8 error_code TEXT,
9 cost_cents INT,
10 submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
11 completed_at TIMESTAMPTZ,
12 user_id TEXT
13);
14
15CREATE UNIQUE INDEX idx_generations_request_id
16 ON generations (request_id);
17
18CREATE INDEX idx_generations_inputs
19 ON generations USING GIN (inputs);
20
21CREATE INDEX idx_generations_submitted
22 ON generations (submitted_at DESC);

The unique index on request_id makes your webhook handler idempotent for free. The GIN index on inputs lets you query any field inside the JSON.

Writing at submit time

Write the row before the render finishes, so spend is captured even if the webhook never arrives.

TS
1import { fal } from "@fal-ai/client";
2import { pool } from "./db";
3
4// or fal-ai/veo4/text-to-video once available
5const ENDPOINT = "fal-ai/veo3.1/text-to-video";
6
7export async function submitRender(userId: string, input: any) {
8 const estimateCents = Math.round(input.duration * 40);
9 const { request_id } = await fal.queue.submit(ENDPOINT, {
10 input,
11 webhookUrl: "https://your-app.com/webhooks/fal",
12 });
13 await pool.query(
14 `INSERT INTO generations (request_id, endpoint, inputs, cost_cents, user_id)
15 VALUES ($1, $2, $3, $4, $5)`,
16 [request_id, ENDPOINT, input, estimateCents, userId]
17 );
18 return request_id;
19}

Veo 3.1 at 1080p is $0.40 per second, so a 6 second clip books at 240 cents. Update in the webhook if actual differs.

Updating from the webhook

TS
1export async function handleWebhook(body: any) {
2 const { request_id, status, payload, error } = body;
3 await pool.query(
4 `UPDATE generations
5 SET status = $1, result_url = $2,
6 error_code = $3, completed_at = NOW()
7 WHERE request_id = $4`,
8 [status, payload?.video?.url ?? null, error?.code ?? null, request_id]
9 );
10}

If fal retries, the UPDATE is safe to run twice. No dedupe logic needed.

Webhook idempotency flow
Webhook idempotency flow

The four queries you will run

Spend by endpoint this week:

SQL
1SELECT endpoint, COUNT(*) AS renders,
2 SUM(cost_cents) / 100.0 AS dollars
3FROM generations
4WHERE submitted_at > NOW() - INTERVAL '7 days'
5 AND status = 'completed'
6GROUP BY endpoint ORDER BY dollars DESC;

Failure rate by day:

SQL
1SELECT DATE(submitted_at) AS day,
2 COUNT(*) FILTER (WHERE status = 'failed') AS failed,
3 COUNT(*) AS total
4FROM generations
5WHERE submitted_at > NOW() - INTERVAL '30 days'
6GROUP BY day ORDER BY day DESC;

All 4K renders (where the GIN index earns its keep):

SQL
1SELECT id, request_id, inputs->>'prompt' AS prompt
2FROM generations
3WHERE inputs @> '{"resolution": "4k"}'
4 AND submitted_at > NOW() - INTERVAL '30 days';

Stuck jobs (submitted but no completion after 10 minutes):

SQL
1SELECT id, request_id, endpoint,
2 EXTRACT(EPOCH FROM (NOW() - submitted_at))::INT AS waiting_s
3FROM generations
4WHERE status = 'submitted'
5 AND submitted_at < NOW() - INTERVAL '10 minutes'
6ORDER BY submitted_at ASC;

Run this every few minutes in a cron. If it returns anything, alert.

Query output: stuck jobs alert
Query output: stuck jobs alert

Why JSONB and not columns

You will be tempted to hoist prompt, duration, resolution, aspect_ratio into their own columns. Do not. Every Veo release adds parameters. A rigid schema means a migration every time fal ships a feature.

The one exception is cost_cents. Pull that out of JSON since it is in every query.

cost_cents at submit is an estimate. Treat your numbers as a close floor. The fal dashboard is the truth for billing.