Skip to content
robovid.ai

API documentation

Three endpoints: create a video, read its status, download the MP4. Run the examples in your own environment, with your key in an environment variable.

Quick start

Base URL: https://api.robovid.ai. All endpoints live under /api/v1 and exchange JSON.

  1. Export your API URL and key.
  2. Create a video with POST /api/v1/videos.
  3. Poll GET /api/v1/videos/{id} about every ten seconds until ready or failed.
  4. Download from GET /api/v1/videos/{id}/content.
Environment
export ROBOVIDAI_API_URL="https://api.robovid.ai"
export ROBOVIDAI_API_KEY="<your pilot key>"
Create a video
curl -sS -X POST "$ROBOVIDAI_API_URL/api/v1/videos" \
  -H "Authorization: Bearer $ROBOVIDAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: morning-tips-0001" \
  -d '{"workflow":"script-to-video","source":{"text":"Most people check their phone within ten minutes of waking up. Try this instead: open the curtains, drink a glass of water, and write down one thing you want to finish today. Three small actions, and your morning starts on your terms."},"metadata":{"campaign":"morning-tips"}}'
Read its status
curl -sS "$ROBOVIDAI_API_URL/api/v1/videos/$VIDEO_ID" \
  -H "Authorization: Bearer $ROBOVIDAI_API_KEY"
Download the MP4
# Read the redirect without following it: the Bearer token must not reach the storage.
LOCATION=$(curl -sS -o /dev/null -w '%{redirect_url}' \
  "$ROBOVIDAI_API_URL/api/v1/videos/$VIDEO_ID/content" \
  -H "Authorization: Bearer $ROBOVIDAI_API_KEY")
curl -sS -o "$VIDEO_ID.mp4" "$LOCATION"

Get a key

Keys are issued by hand during the pilot. Request access; you receive a key tied to your account. Store it in a secret manager or an environment variable, call the API from your server, and never ship the key in a web page or a mobile app.

Authentication

Send the key in the Authorization header of every request to /api/v1/videos:

Header
Authorization: Bearer <your pilot key>

A missing or invalid key returns 401 UNAUTHORIZED. Videos are visible only with a key of the account that created them; any other key gets 404.

Create a video

POST /api/v1/videos with Content-Type: application/json (body up to 64 KiB) and an Idempotency-Key header.

Idempotency-Key

  • Required. 1 to 128 characters among letters, digits, . _ : -.
  • Generate one key per video and reuse it for every retry of that creation: after a timeout, a 503 or a network error, the same key and the same body return the original 202 instead of creating a second video.
  • For 24 hours, the same key with a different body returns 409 IDEMPOTENCY_CONFLICT.

Script to video

The text is narrated verbatim, after trimming leading and trailing whitespace. metadata holds your own references and is returned as is.

script-to-video body
{
  "workflow": "script-to-video",
  "source": {
    "text": "Most people check their phone within ten minutes of waking up. Try this instead: open the curtains, drink a glass of water, and write down one thing you want to finish today. Three small actions, and your morning starts on your terms."
  },
  "metadata": {
    "campaign": "morning-tips"
  }
}

Prompt to video

A script is generated from the topic. durationSeconds is a target, not a guarantee; music.enabled defaults to true.

prompt-to-video body
{
  "workflow": "prompt-to-video",
  "source": {
    "prompt": "Three surprising facts about octopuses, for a curious general audience",
    "durationSeconds": 30
  },
  "music": {
    "enabled": true
  }
}

Unknown fields are rejected, not ignored. The response is 202 Accepted with a Location header pointing to the video:

202 response
{
  "id": "3f6c1b8e-6a4d-4f6e-9b1a-2c7d5e8f9a10",
  "status": "queued",
  "createdAt": "2026-10-19T09:30:00.000Z"
}

Track the status

GET /api/v1/videos/{id} returns 200 for every known video, including failed ones: a generation failure is described by the resource, not by the HTTP status.

statusMeaningWhat to do
queuedAccepted, waiting for a worker.Keep polling.
processingBeing generated; stage tells which step runs.Keep polling.
readyThe MP4 can be downloaded; url is set.Stop polling and download.
failedGeneration stopped; error explains why.Stop polling. Retry with a new idempotency key.

While processing, stage runs through scripting, voicing, sourcing and rendering.

processing
{
  "id": "3f6c1b8e-6a4d-4f6e-9b1a-2c7d5e8f9a10",
  "workflow": "script-to-video",
  "status": "processing",
  "stage": "voicing",
  "url": null,
  "expiresAt": null,
  "error": null,
  "metadata": {
    "campaign": "morning-tips"
  },
  "createdAt": "2026-10-19T09:30:00.000Z",
  "updatedAt": "2026-10-19T09:30:41.000Z"
}

Polling

  • Poll about every ten seconds; faster polling does not make the video faster.
  • Stop as soon as the status is ready or failed: both are final.
  • Bound your wait. A job that has not finished within its one-hour deadline ends in failed; waiting a little longer than that is enough.
  • On 429, wait for the number of seconds given by Retry-After.

Generation errors

error.codeMeaning
SCRIPT_FAILEDThe script could not be generated from the topic.
TTS_FAILEDThe narration could not be produced.
SOURCING_FAILEDFootage could not be prepared.
RENDER_FAILEDThe video could not be rendered.
DELIVERY_FAILEDThe rendered file could not be stored.
JOB_TIMEOUTThe job exceeded its overall deadline.
QUEUE_TIMEOUTThe job never started before its deadline.

Download the MP4

Once ready, the url field holds the download path, relative to the API, and expiresAt the end of availability:

ready
{
  "id": "3f6c1b8e-6a4d-4f6e-9b1a-2c7d5e8f9a10",
  "workflow": "script-to-video",
  "status": "ready",
  "stage": null,
  "url": "/api/v1/videos/3f6c1b8e-6a4d-4f6e-9b1a-2c7d5e8f9a10/content",
  "expiresAt": "2026-10-26T09:33:12.000Z",
  "error": null,
  "metadata": {
    "campaign": "morning-tips"
  },
  "createdAt": "2026-10-19T09:30:00.000Z",
  "updatedAt": "2026-10-19T09:33:12.000Z"
}
  • Call url with your Authorization header. The API answers 302 to a signed storage URL valid for at most 5 minutes.
  • Follow that redirect without the Authorization header: your key must never reach the storage, which also rejects a second authentication.
  • Do not store the signed URL. To download again, call the API again.
  • Before the video is ready the API answers 409 VIDEO_NOT_READY; after 7 days the file is deleted, url becomes null and the download answers 410 VIDEO_EXPIRED.

Errors and rate limits

A rejected request returns an HTTP error status and a body with a stable code. Test error.code, not the message. field points to the invalid field when there is one.

Error body
{
  "error": {
    "code": "TEXT_TOO_LONG",
    "message": "The text exceeds the allowed length.",
    "field": "source.text"
  }
}
HTTPerror.codeMessage
400INVALID_JSONThe request body is not valid JSON.
400IDEMPOTENCY_KEY_REQUIREDThe Idempotency-Key header is required.
400INVALID_IDEMPOTENCY_KEYThe Idempotency-Key header is malformed.
401UNAUTHORIZEDA valid API key is required.
404NOT_FOUNDThe requested video does not exist.
409IDEMPOTENCY_CONFLICTThis idempotency key was used with a different input.
409VIDEO_NOT_READYThe video is not available yet.
410VIDEO_EXPIREDThe video has expired.
413BODY_TOO_LARGEThe request body exceeds the allowed size.
415UNSUPPORTED_MEDIA_TYPEContent-Type must be application/json.
422INVALID_BODYThe request body does not match the expected schema.
422TEXT_EMPTYThe text must not be empty.
422TEXT_TOO_LONGThe text exceeds the allowed length.
429RATE_LIMITEDToo many requests. Retry later.
503SERVICE_UNAVAILABLEThe service is temporarily unavailable.

Requests are rate limited per key, and creation is refused while too many videos are pending. Both cases return 429 RATE_LIMITED with a Retry-After header in seconds. Retry 429 and 503 a bounded number of times, with the same idempotency key for a creation. Do not retry other 4xx errors unchanged.

Example client

A dependency-free TypeScript client for Bun or Node.js 18+. Every retry and wait is bounded, so the whole flow never outlives its maximum wait. Save it as robovidai-client.ts.

robovidai-client.ts
/**
 * RobovidAI API client example: create a video, wait for it, download the MP4.
 *
 * No dependency besides `fetch`: runs as is with Bun or Node.js 18+. Every wait
 * is bounded, retries included, so `waitForVideo()` never outlives `maxWaitMs`.
 *
 *   ROBOVIDAI_API_KEY=... (your pilot key, never committed)
 *   ROBOVIDAI_API_URL=https://api.robovid.ai (optional)
 */

export type VideoStatus = "queued" | "processing" | "ready" | "failed";

export type Video = {
  id: string;
  workflow: "script-to-video" | "prompt-to-video";
  status: VideoStatus;
  stage: "scripting" | "voicing" | "sourcing" | "rendering" | null;
  url: string | null;
  expiresAt: string | null;
  error: { code: string; message: string; stage: string | null } | null;
};

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
  ) {
    super(`${status} ${code}: ${message}`);
    this.name = "ApiError";
  }
}

const API_URL = process.env.ROBOVIDAI_API_URL ?? "https://api.robovid.ai";
const API_KEY = process.env.ROBOVIDAI_API_KEY ?? "";

/** About every ten seconds: a 30-second video usually takes a few minutes. */
const POLL_INTERVAL_MS = 10_000;
/** A job has a one-hour deadline; wait a little longer, never forever. */
const MAX_WAIT_MS = 65 * 60_000;
/** Attempts per call on 429, 503 or a network error. */
const MAX_ATTEMPTS = 4;
const REQUEST_TIMEOUT_MS = 30_000;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** Delay requested by the server through `Retry-After`, in milliseconds. */
function retryAfterMs(res: Response, fallbackMs: number): number {
  const seconds = Number(res.headers.get("retry-after"));
  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : fallbackMs;
}

async function toApiError(res: Response): Promise<ApiError> {
  const body = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string } } | null;
  return new ApiError(res.status, body?.error?.code ?? "UNKNOWN", body?.error?.message ?? res.statusText);
}

/**
 * Sends an authenticated request, retrying 429, 503 and network errors.
 *
 * Retries are bounded twice: by `MAX_ATTEMPTS`, and by `deadline` -- a wait
 * that would end after it is not started. Any other status is returned as is.
 */
async function send(path: string, init: RequestInit, deadline: number): Promise<Response> {
  let lastError: unknown = new Error(`no attempt made before the deadline for ${path}`);
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    let waitMs = 1000 * attempt;
    try {
      const res = await fetch(new URL(path, API_URL), {
        ...init,
        headers: { authorization: `Bearer ${API_KEY}`, ...init.headers },
        signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
      });
      if (res.status !== 429 && res.status !== 503) return res;
      waitMs = retryAfterMs(res, waitMs);
      lastError = await toApiError(res);
    } catch (error) {
      // Network error or timeout: a creation may have succeeded, hence the
      // same idempotency key on the next attempt.
      lastError = error;
    }
    if (attempt === MAX_ATTEMPTS || Date.now() + waitMs > deadline) break;
    await sleep(waitMs);
  }
  throw lastError;
}

/** Creates a video. Keep the same `idempotencyKey` when you retry the same creation. */
export async function createVideo(body: unknown, idempotencyKey: string = crypto.randomUUID()) {
  const res = await send(
    "/api/v1/videos",
    {
      method: "POST",
      headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
      body: JSON.stringify(body),
    },
    Date.now() + 5 * 60_000,
  );
  if (res.status !== 202) throw await toApiError(res);
  return (await res.json()) as { id: string; status: "queued"; createdAt: string };
}

export async function getVideo(id: string, deadline: number = Date.now() + 60_000): Promise<Video> {
  const res = await send(`/api/v1/videos/${encodeURIComponent(id)}`, {}, deadline);
  if (res.status !== 200) throw await toApiError(res);
  return (await res.json()) as Video;
}

/** Polls until `ready` or `failed`, and gives up after `maxWaitMs`. */
export async function waitForVideo(
  id: string,
  options: { maxWaitMs?: number; onUpdate?: (video: Video) => void } = {},
): Promise<Video> {
  const deadline = Date.now() + (options.maxWaitMs ?? MAX_WAIT_MS);
  for (;;) {
    const video = await getVideo(id, deadline);
    options.onUpdate?.(video);
    if (video.status === "ready" || video.status === "failed") return video;
    if (Date.now() + POLL_INTERVAL_MS > deadline) {
      throw new Error(`video ${id} still ${video.status} after ${Math.round((options.maxWaitMs ?? MAX_WAIT_MS) / 1000)} s`);
    }
    await sleep(POLL_INTERVAL_MS);
  }
}

/**
 * Downloads the MP4 of a `ready` video.
 *
 * The API answers `302` to a signed storage URL valid for a few minutes. The
 * redirect is followed without the `Authorization` header: your API key must
 * never reach the storage. Do not keep the signed URL; call the API again to
 * download again. After expiry, the API answers `410 VIDEO_EXPIRED`.
 */
export async function downloadVideo(video: Video): Promise<Uint8Array> {
  if (!video.url) throw new Error(`video ${video.id} has no file available (${video.status})`);
  const res = await send(video.url, { redirect: "manual" }, Date.now() + 60_000);
  if (res.status === 302) {
    const location = res.headers.get("location");
    if (!location) throw new Error(`redirect without Location for video ${video.id}`);
    const file = await fetch(location, { signal: AbortSignal.timeout(5 * 60_000) });
    if (!file.ok) throw new Error(`storage answered HTTP ${file.status} for video ${video.id}`);
    return new Uint8Array(await file.arrayBuffer());
  }
  if (res.status !== 200) throw await toApiError(res);
  return new Uint8Array(await res.arrayBuffer());
}
Usage
import { writeFile } from "node:fs/promises";
import { createVideo, downloadVideo, waitForVideo } from "./robovidai-client.ts";

const created = await createVideo({
  workflow: "script-to-video",
  source: { text: "Your exact script, narrated verbatim." },
});
const video = await waitForVideo(created.id, {
  onUpdate: (v) => console.log(v.status, v.stage ?? ""),
});
if (video.status === "failed") throw new Error(`${video.error?.code}: ${video.error?.message}`);
await writeFile(`${video.id}.mp4`, await downloadVideo(video));

OpenAPI

The full description is public, with no key required: https://api.robovid.ai/api/v1/openapi.json. Use it to generate a client or to check field-level rules.

MCP server

An MCP server for AI assistants is planned. Connection instructions will be published once it is available; until then, use the REST API.

Need a key to try these calls?

Request pilot access