Rendering on demand
Your format is a template. Your code decides when and with what data to render it.
This page shows the patterns for calling the render API from your own systems. The core flow is always the same — the only thing that changes is what triggers it.
The core flow
fetch data → POST /renders with variables → poll → download MP4 → post to destination
Here is a single self-contained Node.js function that implements the full loop. Every pattern below is just a different way to call it:
// render.ts
const BASE = "https://your-domain.com/api/v1";
const API_KEY = process.env.API_KEY!;
const FORMAT_SLUG = process.env.FORMAT_SLUG!; // e.g. "daily-sports-recap"
export interface RenderVariables {
[key: string]: string | number | boolean | null;
}
export async function renderFormat(variables: RenderVariables): Promise<string> {
// 1. Enqueue the render
const renderRes = await fetch(`${BASE}/formats/${FORMAT_SLUG}/renders`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ variables }),
});
if (!renderRes.ok) {
const err = await renderRes.json();
throw new Error(`Render enqueue failed: ${err.error?.code} — ${err.error?.message}`);
}
const { id } = await renderRes.json();
console.log(`Render queued: ${id}`);
// 2. Poll until complete (3-5 s interval)
for (let attempt = 0; attempt < 120; attempt++) {
await new Promise((r) => setTimeout(r, 4000));
const statusRes = await fetch(`${BASE}/renders/${id}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const job = await statusRes.json();
if (job.status === "completed") break;
if (job.status === "failed") throw new Error(`Render ${id} failed`);
console.log(` progress: ${Math.round((job.progress ?? 0) * 100)}%`);
}
// 3. Fetch the presigned download URL (valid 24 hours)
const urlRes = await fetch(`${BASE}/renders/${id}/signed-url`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { url } = await urlRes.json();
return url;
}
Discover which variables your format accepts by calling GET /api/v1/formats/:slug/schema — it returns the full variable list with types, defaults, and an example body. See step 6 of the Quickstart.
Trigger patterns
Scheduled (cron)
Run the script on any Linux/macOS host. Add an entry to crontab:
# Render every morning at 6 AM
0 6 * * * node /path/to/render.js >> /var/log/render.log 2>&1
Your render.js imports renderFormat, fetches fresh data from your data source, and calls it:
import { renderFormat } from "./render.js";
const mp4Url = await renderFormat({ "game1.player.name": "LeBron James", "game1.team.score": 112 });
console.log("MP4:", mp4Url);
Scheduled (Vercel Cron)
In vercel.json, declare the schedule:
{
"crons": [
{ "path": "/api/cron/render-recap", "schedule": "0 6 * * *" }
]
}
Then create the route handler:
// src/app/api/cron/render-recap/route.ts
import { renderFormat } from "@/lib/render";
export async function GET() {
const url = await renderFormat({ "game1.player.name": "LeBron James" });
return Response.json({ url });
}
Vercel injects CRON_SECRET automatically; add Authorization: Bearer ${process.env.CRON_SECRET} verification if you want to restrict the route.
Scheduled (GitHub Actions)
# .github/workflows/daily-render.yml
on:
schedule:
- cron: "0 6 * * *"
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: node scripts/render.js
env:
API_KEY: ${{ secrets.VIDEOAI_API_KEY }}
FORMAT_SLUG: daily-sports-recap
Event-driven (webhook)
Render when an external system fires a webhook — e.g. when a game ends, an order ships, or a listing goes live.
// Next.js App Router — src/app/api/webhooks/game-ended/route.ts
import { renderFormat } from "@/lib/render";
export async function POST(req: Request) {
const payload = await req.json();
// Optionally verify the webhook signature here
const mp4Url = await renderFormat({
"game1.player.name": payload.mvp,
"game1.team.score": payload.finalScore,
"game1.vegas.spread": payload.spread,
});
// Post mp4Url to Slack, CMS, or storage
await notifyTeam(mp4Url);
return Response.json({ ok: true });
}
For long-running renders, respond 202 Accepted immediately and move the renderFormat call to a background queue.
User action
Render when a user clicks a button in your product.
// Frontend button handler (React)
async function handleRenderClick() {
const res = await fetch("/api/render-on-demand", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerId: selectedPlayer.id }),
});
const { jobId } = await res.json();
startPollingJobStatus(jobId); // update UI as render progresses
}
// Your backend route — src/app/api/render-on-demand/route.ts
export async function POST(req: Request) {
const { playerId } = await req.json();
const player = await db.players.findById(playerId);
const renderRes = await fetch(`${BASE}/formats/${FORMAT_SLUG}/renders`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ variables: { "card.player.name": player.name } }),
});
const { id } = await renderRes.json();
return Response.json({ jobId: id });
}
Batch
Render one video per entity — every game, every product, every listing — in a loop.
// scripts/batch-render.ts
import { renderFormat } from "./render.js";
const games = await fetchTodaysGames(); // your data source
for (const game of games) {
console.log(`Rendering ${game.id}...`);
const url = await renderFormat({
"game1.player.name": game.mvp,
"game1.team.score": game.finalScore,
"game1.vegas.spread": game.spread,
});
await saveRenderUrl(game.id, url);
}
For large batches, consider running renders concurrently with a pool (e.g. p-limit) to respect the platform's concurrency quota. Each renderFormat call is independent — there is no shared state between renders.
Operational notes
Idempotency key. If your trigger might fire twice (at-least-once webhook delivery, cron overlap), pass an idempotencyKey in the render body. The platform de-duplicates renders with the same key within a short window:
{ "variables": { ... }, "idempotencyKey": "game-123-2026-04-21" }
Polling cadence. Poll every 3–5 seconds. A 30-second render at 3-second polling = 10 HTTP calls. Alternatively, configure webhooks to receive a push when rendering completes.
Error handling. Check for 422 errors before your loop starts — they indicate a bad variable name or type, not a transient failure. Retry 5xx errors with exponential backoff (max 3 attempts). A failed render status is terminal; enqueue a new render rather than retrying the job ID.
Rate limits. The API enforces per-org concurrency and render quota. If you hit a limit, you will receive a 429 response with a Retry-After header. Back off and retry.
Keeping your integration current. When an author renames a variable on the format, callers sending the old name receive 422 unknown_variable. Re-fetch GET /formats/:slug/schema periodically (or on 422) to detect contract changes.
Testing locally
You can run the full render pipeline locally against a development stack. Refer to the project's developer README for setup, but the short version:
- Start MinIO (Spaces-compatible local object storage) and a local Postgres instance.
- Run
npm run devto start the Next.js dev server. - Run
npm run worker:devto start the render worker. - Set
BASE=http://localhost:3000/api/v1and use a dev API key fromhttp://localhost:3000/settings/api-keys. - Call the same
renderFormatfunction — locally it renders to MinIO and the signed URL resolves againstlocalhost.
No production quota is consumed during local development.