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.
- Export your API URL and key.
- Create a video with
POST /api/v1/videos. - Poll
GET /api/v1/videos/{id}about every ten seconds untilreadyorfailed. - Download from
GET /api/v1/videos/{id}/content.
export ROBOVIDAI_API_URL="https://api.robovid.ai"
export ROBOVIDAI_API_KEY="<your pilot key>"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"}}'curl -sS "$ROBOVIDAI_API_URL/api/v1/videos/$VIDEO_ID" \
-H "Authorization: Bearer $ROBOVIDAI_API_KEY"# 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:
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
503or a network error, the same key and the same body return the original202instead 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.
{
"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.
{
"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:
{
"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.
| status | Meaning | What to do |
|---|---|---|
queued | Accepted, waiting for a worker. | Keep polling. |
processing | Being generated; stage tells which step runs. | Keep polling. |
ready | The MP4 can be downloaded; url is set. | Stop polling and download. |
failed | Generation stopped; error explains why. | Stop polling. Retry with a new idempotency key. |
While processing, stage runs through scripting, voicing, sourcing and rendering.
{
"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
readyorfailed: 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 byRetry-After.
Generation errors
| error.code | Meaning |
|---|---|
SCRIPT_FAILED | The script could not be generated from the topic. |
TTS_FAILED | The narration could not be produced. |
SOURCING_FAILED | Footage could not be prepared. |
RENDER_FAILED | The video could not be rendered. |
DELIVERY_FAILED | The rendered file could not be stored. |
JOB_TIMEOUT | The job exceeded its overall deadline. |
QUEUE_TIMEOUT | The 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:
{
"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
urlwith yourAuthorizationheader. The API answers302to a signed storage URL valid for at most 5 minutes. - Follow that redirect without the
Authorizationheader: 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,urlbecomesnulland the download answers410 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": {
"code": "TEXT_TOO_LONG",
"message": "The text exceeds the allowed length.",
"field": "source.text"
}
}| HTTP | error.code | Message |
|---|---|---|
| 400 | INVALID_JSON | The request body is not valid JSON. |
| 400 | IDEMPOTENCY_KEY_REQUIRED | The Idempotency-Key header is required. |
| 400 | INVALID_IDEMPOTENCY_KEY | The Idempotency-Key header is malformed. |
| 401 | UNAUTHORIZED | A valid API key is required. |
| 404 | NOT_FOUND | The requested video does not exist. |
| 409 | IDEMPOTENCY_CONFLICT | This idempotency key was used with a different input. |
| 409 | VIDEO_NOT_READY | The video is not available yet. |
| 410 | VIDEO_EXPIRED | The video has expired. |
| 413 | BODY_TOO_LARGE | The request body exceeds the allowed size. |
| 415 | UNSUPPORTED_MEDIA_TYPE | Content-Type must be application/json. |
| 422 | INVALID_BODY | The request body does not match the expected schema. |
| 422 | TEXT_EMPTY | The text must not be empty. |
| 422 | TEXT_TOO_LONG | The text exceeds the allowed length. |
| 429 | RATE_LIMITED | Too many requests. Retry later. |
| 503 | SERVICE_UNAVAILABLE | The 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 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());
}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?