Back to blog
February 10, 2025Updated July 13, 202610 min readiLoveVideoEditor Team

Video Editor API: A Developer's Guide to Programmatic Video

Render videos programmatically with the iLoveVideoEditor REST API: VideoJSON, the Node SDK, template variables, webhooks, and MCP tools.

APIDevelopersTutorial

Programmatic video usually means one of three things: wrestling an FFmpeg pipeline into production, paying a cloud render service per-minute fees that scale badly, or giving up and exporting by hand. The iLoveVideoEditor API is a fourth option: describe the video as JSON, POST it, poll or receive a webhook, download an MP4.

This guide walks the whole flow with copy-paste code: the REST API, the official Node SDK, the VideoJSON scene format, rendering templates with variables, signed webhooks, error handling, and driving the same pipeline from an AI agent over MCP.

What you're building against

  • Base URL: https://api.ilovevideoeditor.com — every route below is prefixed with /v1.
  • Auth: an API key from the dashboard, sent as the x-api-key header. JWT bearer tokens work too, but the key is what you want for server-side jobs.
  • Cost model: renders consume credits. As a rule of thumb, 1 credit covers about a minute of 1080p30 output, and you can estimate the cost of any payload before spending anything — see the pricing section.

Everything the browser editor can do is expressible through the API, because the editor itself produces the same VideoJSON the API consumes.

Quick start with the Node SDK

The official SDK is a zero-dependency TypeScript client that runs anywhere fetch exists (Node 18+, Deno, Bun, Cloudflare Workers). Heads up: the package is preparing for its npm release — until it lands, everything in this guide works with plain fetch against the REST endpoints, and each SDK example names the route it calls so translating it is a one-liner.

import { ILoveVideoEditorClient } from '@ilovevideoeditor/sdk-node';

const client = new ILoveVideoEditorClient({
  apiKey: process.env.VF_API_KEY!,
  // baseUrl defaults to https://api.ilovevideoeditor.com
});

const result = await client.render(videoJSON, {
  pollIntervalMs: 2000, // how often to poll (default 2s)
  maxPollTimeMs: 300_000, // give up after 5 minutes (default)
  onProgress: ({ status, progress }) => {
    console.log(status + ' — ' + progress + '%');
  },
});

if (result.status === 'completed') {
  console.log('Download URL:', result.downloadUrl);
} else {
  console.error('Render failed:', result.error);
}

client.render() queues the job and polls until it reaches a terminal state, then refreshes the download URL for you. To control the loop yourself — for example inside a queue worker — use the two-step version:

const { jobId } = await client.queueRender(videoJSON); // POST /v1/render
const status = await client.getRender(jobId); // GET /v1/render/:id

The RenderResult shape: jobId, status ('pending' | 'rendering' | 'completed' | 'failed'), progress, url, downloadUrl, error, createdAt, completedAt. When a completed render's URL expires, call client.refreshUrl(jobId) (or POST /v1/render/:id/refresh-url) for a fresh one — you don't need to re-render.

VideoJSON: the scene format

VideoJSON is a declarative scene graph. The rules that matter:

  • The document is { name, layers }. The first layer must be a `composition` whose settings define the canvas: width (max 7680), height (max 4320), fps (max 120), and sourceDuration — the total length in seconds (max 3600).
  • Every layer has id, type, settings, properties, and animations. settings carries timing: startTime (when the layer appears on the timeline), sourceDuration (how long it plays, in source seconds), plus media fields like source for video, image, and audio layers.
  • properties holds static values. position and anchor are normalized [x, y] arrays — [0.5, 0.5] is dead center, [0, 0] is top-left.
  • Anything that changes over time goes in animations as keyframes. Valid easing values are step, linear, easeIn, easeOut, and easeInOut (a missing easing means linear).
  • GLSL effects attach as { effect, params } entries and run in array order; transitionIn / transitionOut name a transition preset with a duration in seconds.
  • Limits: up to 50 layers, group nesting depth 3.

A complete, valid example — a 3-second 1080p30 clip with a background video, a vignette effect, a fade-in transition, and an animated title:

{
  "name": "hello-api",
  "version": 2,
  "layers": [
    {
      "id": "root",
      "type": "composition",
      "settings": {
        "enabled": true,
        "startTime": 0,
        "sourceDuration": 3,
        "width": 1920,
        "height": 1080,
        "fps": 30,
        "backgroundColor": "#0f0f12"
      },
      "properties": {},
      "animations": []
    },
    {
      "id": "bg",
      "type": "video",
      "settings": {
        "enabled": true,
        "startTime": 0,
        "sourceDuration": 3,
        "name": "Background",
        "source": "https://example.com/clip.mp4"
      },
      "properties": {
        "fit": "cover",
        "position": [0.5, 0.5],
        "scale": 1,
        "opacity": 1
      },
      "animations": [],
      "transitionIn": { "transition": "fade", "duration": 0.5 },
      "effects": [
        {
          "effect": "vignette",
          "params": { "intensity": 0.6, "radius": 0.9, "softness": 0.4 }
        }
      ]
    },
    {
      "id": "title",
      "type": "text",
      "settings": {
        "enabled": true,
        "startTime": 0,
        "sourceDuration": 3,
        "name": "Title"
      },
      "properties": {
        "text": "Hello from the API",
        "fontFamily": "Noto Sans",
        "fontSize": 96,
        "fontWeight": 700,
        "color": "#FFFFFF",
        "position": [0.5, 0.5]
      },
      "animations": [
        {
          "id": "title-fade",
          "property": "opacity",
          "keyframes": [
            { "time": 0, "value": 0, "easing": "easeOut" },
            { "time": 0.5, "value": 1 }
          ]
        }
      ]
    }
  ]
}

The full catalog of what you can put in effects, transitionIn, and animations lives in the docs: 43 GLSL effects, 27 transitions, and the animation presets.

You don't have to hand-write any of this: build the scene visually in the studio, then copy the project's VideoJSON. The editor is the reference implementation.

Render a template with variables

Hand-rolling VideoJSON makes sense when every video is bespoke. When your videos share a structure — product launches, real-estate reels, quote cards — use a template. The library ships 249 templates, each with a typed variablesSchema.

The SDK lists and fetches templates:

const { templates } = await client.listTemplates(); // GET /v1/templates
const { template } = await client.getTemplate('ecommerce-product-launch');

console.log(template.variablesSchema);
// [{ key: 'productName', type: 'text', required: true, ... }, ...]

Rendering a template is one REST call — POST /v1/templates/:id/render with the variable values. The SDK doesn't wrap this endpoint yet, so use fetch:

const res = await fetch(
  'https://api.ilovevideoeditor.com/v1/templates/ecommerce-product-launch/render',
  {
    method: 'POST',
    headers: {
      'x-api-key': process.env.VF_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      variables: {
        productName: 'Aurora Headphones',
        tagline: 'Sound without compromise',
        price: '€199',
        cta: 'Shop now',
      },
      webhookUrl: 'https://api.example.com/render-done', // optional
    }),
  },
);

if (!res.ok) {
  const body = await res.json();
  throw new Error('Template render failed: ' + JSON.stringify(body));
}

const { jobId } = await res.json();

The server merges your variables with the template's defaults and returns 400 when a required variable is missing — the error body is { error: 'Missing required template variables', details: [...] }. From there the job behaves exactly like a raw render: same status endpoint, same webhooks.

Polling vs webhooks

For interactive flows, polling with client.render() is fine. For production pipelines — where a render might take minutes and your HTTP handler shouldn't sit open — subscribe to webhooks once and let the API call you:

const res = await fetch('https://api.ilovevideoeditor.com/v1/webhooks', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.VF_API_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://api.example.com/ilve-webhook',
    events: ['render.completed', 'render.failed'],
  }),
});

const subscription = await res.json();
// { id, url, events, secret, createdAt } — store `secret`; it verifies every delivery

Two things to know:

  • You can have up to 10 active subscriptions. Manage them with GET /v1/webhooks and DELETE /v1/webhooks/:id.
  • There's also a per-job alternative: pass webhookUrl in the POST /v1/render (or template render) body. Useful for one-off jobs whose result routes into a specific workflow.

The payload for a completed render looks like this (a failed render carries event: 'render.failed' and an error message):

{
  "event": "render.completed",
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "completed",
  "stage": "done",
  "progress": 100,
  "url": "https://cdn.ilovevideoeditor.com/renders/a1b2c3d4.mp4",
  "duration": 12.5,
  "width": 1920,
  "height": 1080,
  "cost": 3,
  "error": null,
  "createdAt": "2026-01-15T10:00:00.000Z",
  "completedAt": "2026-01-15T10:01:30.000Z"
}

Every delivery is signed. Verify the X-ILVE-Signature header (format t=<unix seconds>,v1=<HMAC-SHA256 hex of "t.body">) against the raw request body before trusting anything:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifySignature(
  rawBody: string,
  header: string,
  secret: string,
): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody)
    .digest('hex');

  const a = Buffer.from(parts.v1 ?? '', 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Failed deliveries are retried up to 3 times (after roughly 5s, 15s, and 45s), so make your handler idempotent on the render id — the same event can arrive more than once. One honest limitation: webhooks fire only on terminal states. There are no progress webhooks, so keep polling if your UI needs a progress bar.

Handling errors

The SDK throws ILoveVideoEditorError with statusCode and the parsed responseBody, which makes failures easy to branch on:

import {
  ILoveVideoEditorClient,
  ILoveVideoEditorError,
} from '@ilovevideoeditor/sdk-node';

try {
  const result = await client.render(videoJSON);
  console.log(result.downloadUrl);
} catch (err) {
  if (err instanceof ILoveVideoEditorError) {
    switch (err.statusCode) {
      case 400: // invalid videoJSON — err.responseBody.details has field errors
      case 401: // missing or invalid API key
      case 404: // job not found (or not yours)
      default:
        console.error(err.statusCode, err.message, err.responseBody);
    }
  }
  throw err;
}

A few behaviors worth building around:

  • Validation happens at submission. A malformed VideoJSON is rejected with 400 before it consumes credits, so fail fast and log details.
  • `render()` has its own timeout (maxPollTimeMs, default 5 minutes). Long videos will outgrow it — either raise it or switch to queueRender plus webhooks.
  • Failed renders refund their credits automatically, and result.error tells you why — an unreachable source URL is the most common cause in practice.

Driving renders with AI agents (MCP)

If video generation is triggered by an LLM — support agents producing personalized replies, ops turning spreadsheet rows into social clips — skip the glue code entirely. The @ilovevideoeditor/mcp-server package exposes the render pipeline as Model Context Protocol tools, so any MCP-capable client (Claude, Cursor, or your own agent runtime) can list templates, render, and fetch results from a prompt. Like the SDK, the MCP server package is preparing for its npm release — the configuration below is what you'll add to your MCP client once it lands.

It reads your API key from VF_API_KEY (and optionally VF_API_BASE_URL):

{
  "mcpServers": {
    "ilovevideoeditor": {
      "command": "npx",
      "args": ["-y", "@ilovevideoeditor/mcp-server"],
      "env": { "VF_API_KEY": "vf_live_..." }
    }
  }
}

The tools the agent gets:

ToolWhat it does
ilovevideoeditor_list_templatesList all templates with their variable schemas
ilovevideoeditor_get_templateFetch one template by ID
ilovevideoeditor_get_layer_capabilitiesDescribe a layer type's properties, effects, and transitions
ilovevideoeditor_render_jsonQueue a raw VideoJSON render
ilovevideoeditor_render_templateCompile a template with variables and queue it
ilovevideoeditor_get_render_statusPoll a job by ID
ilovevideoeditor_get_download_urlGet a fresh download URL for a completed job
ilovevideoeditor_local_previewOpen a local preview of the composition
ilovevideoeditor_local_captureCapture a frame as PNG or JPEG
ilovevideoeditor_local_analyzeAnalyze a rendered frame

The local_* tools are the underrated part: the agent previews the composition and inspects captured frames locally before spending a single credit on a cloud render.

What it costs

Rendering is metered in credits. Pro is €25/month for 100 credits, Business is €50/month for 250, and one-time top-up packages start at 800 credits and never expire. Roughly, 1 credit buys a minute of 1080p30 output — see the pricing page for the current tiers.

Don't guess costs: POST /v1/render/cost with your VideoJSON returns the exact credit cost, estimated duration, resolution, and fps without consuming anything. Wire it into your UI so users see the price before they commit:

const res = await fetch('https://api.ilovevideoeditor.com/v1/render/cost', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.VF_API_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ videoJSON }),
});

const { cost, estimatedDuration, resolution, fps } = await res.json();
// cost: the credits this render will consume

Where to go next

Start small: one template, one webhook, one render. The JSON the API accepts is the same JSON the editor produces, so everything you prototype visually is one POST away from production.

Try it yourself — free, in your browser

No install, no upload, no credit card. Open the studio or start from one of 259 motion-designed templates.

Get new tutorials in your inbox