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.

The schema
One table. Resist the urge to normalize inputs into columns.
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 TEXT13);1415CREATE UNIQUE INDEX idx_generations_request_id16 ON generations (request_id);1718CREATE INDEX idx_generations_inputs19 ON generations USING GIN (inputs);2021CREATE INDEX idx_generations_submitted22 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.
1import { fal } from "@fal-ai/client";2import { pool } from "./db";34// or fal-ai/veo4/text-to-video once available5const ENDPOINT = "fal-ai/veo3.1/text-to-video";67export 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
1export async function handleWebhook(body: any) {2 const { request_id, status, payload, error } = body;3 await pool.query(4 `UPDATE generations5 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.

The four queries you will run
Spend by endpoint this week:
1SELECT endpoint, COUNT(*) AS renders,2 SUM(cost_cents) / 100.0 AS dollars3FROM generations4WHERE submitted_at > NOW() - INTERVAL '7 days'5 AND status = 'completed'6GROUP BY endpoint ORDER BY dollars DESC;
Failure rate by day:
1SELECT DATE(submitted_at) AS day,2 COUNT(*) FILTER (WHERE status = 'failed') AS failed,3 COUNT(*) AS total4FROM generations5WHERE submitted_at > NOW() - INTERVAL '30 days'6GROUP BY day ORDER BY day DESC;
All 4K renders (where the GIN index earns its keep):
1SELECT id, request_id, inputs->>'prompt' AS prompt2FROM generations3WHERE inputs @> '{"resolution": "4k"}'4 AND submitted_at > NOW() - INTERVAL '30 days';
Stuck jobs (submitted but no completion after 10 minutes):
1SELECT id, request_id, endpoint,2 EXTRACT(EPOCH FROM (NOW() - submitted_at))::INT AS waiting_s3FROM generations4WHERE 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.

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.