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:

  1. Start MinIO (Spaces-compatible local object storage) and a local Postgres instance.
  2. Run npm run dev to start the Next.js dev server.
  3. Run npm run worker:dev to start the render worker.
  4. Set BASE=http://localhost:3000/api/v1 and use a dev API key from http://localhost:3000/settings/api-keys.
  5. Call the same renderFormat function — locally it renders to MinIO and the signed URL resolves against localhost.

No production quota is consumed during local development.