Developers
Build Roilo's product videos into your own shop, tool or workflow, at the same credit prices as the website.
Overview
No account yet? Create one for free, then make an API key under your avatar menu → API keys.
The Roilo API makes the same videos the website makes: you send product photos and a mode, Roilo writes the script, generates the scenes, adds captions and voice, and returns a finished video. Everything is JSON over HTTPS at https://roilo.co/api/v1. There is also a hosted MCP server for AI assistants (see MCP server below).
Credits are charged the same way as on the website: a hold when the video starts, the charge when it is delivered, and a refund if it fails. Each mode's cost is in GET /v1/modes.
The usual flow: (1) pick a mode from GET /v1/modes; (2) upload photos, or use public https links; (3) POST /v1/videos; (4) wait for the video.completed webhook, or poll GET /v1/videos/:id until status is completed or failed; (5) download output.video_url.
| Route | Success | Purpose |
|---|---|---|
| GET /v1/me | 200 | Check the key; read credits and this key’s spend. |
| GET /v1/modes, /actors, /voices, /caption-styles | 200 | Look up the ids the create call accepts. |
| POST /v1/files | 201 | Upload one image. |
| POST /v1/videos | 202 | Start a video. |
| GET /v1/videos | 200 | List videos, newest first. |
| GET /v1/videos/:id | 200 | Read one video. |
| POST /v1/videos/:id/approve | 200 | Release a video held for review. |
| DELETE /v1/videos/:id | 204 | Delete a video. |
Conventions: request and response bodies are JSON (uploads are multipart); timestamps are ISO 8601 in UTC; video ids start with vid_ and file ids with file_; a video you do not own, or that does not exist, is 404 not_found.
Authentication
Create a key under API keys in your account menu (a verified email is required; up to 10 live keys). The key starts with rk_live_ and is shown once. Send it as a bearer token on every request:
curl https://roilo.co/api/v1/me \
-H "Authorization: Bearer rk_live_..."A missing, malformed or revoked key gets 401 invalid_api_key. Each key can carry an optional monthly credit limit; when it is reached, creates get 402 key_credit_limit_reached. Keys only reach the API routes on this page, never the website's own routes.
Create a video
POST /v1/videos returns 202 with the video object. Image references are either a file_… id from Upload a file, or a public https:// link (no IP addresses, no local host names; at most 2048 characters). Links are fetched before generation starts.
curl -X POST https://roilo.co/api/v1/videos \
-H "Authorization: Bearer rk_live_..." \
-H "Idempotency-Key: order-8731-v1" \
-H "Content-Type: application/json" \
-d '{
"mode": "balance",
"product_images": ["file_2f1c...", "https://cdn.example.com/shoe-side.jpg"],
"aspect_ratio": "9:16",
"product": { "name": "Trail runner", "category": "fashion" },
"webhook_url": "https://example.com/hooks/roilo",
"metadata": { "order_id": "8731" }
}'| Field | Type | Notes |
|---|---|---|
| mode | string, required | An id from GET /v1/modes. |
| product_images | string[] ≤ 20 | file_ ids or https links. Each mode sets a min and max (see /v1/modes). |
| actor_images | string[] ≤ 10 | Actor reference photos. A product video needs the minimum the mode sets unless actor_id is sent; a service video needs actor_id or an actor image. |
| venue_images | string[] ≤ 20 | Service videos only. |
| business_type | "product" | "service" | Default product. |
| actor_id | uuid | An id from GET /v1/actors. |
| aspect_ratio | "16:9" | "1:1" | "9:16" | Default 9:16. |
| quality | "480p" | "720p" | "1080p" | Default from the mode. |
| voice_id | uuid | An id from GET /v1/voices. |
| caption_style_id | uuid | An id from GET /v1/caption-styles. |
| product | object | The brief: name (≤160), category, target_audience (≤480), dream_outcome (≤480), pain_point (≤480), competitive_advantage (≤800), social_proof (≤800), result (≤240). |
| business_name | string ≤ 30 | Service videos only. Shown in the video. |
| review | boolean | Pause before generation so you can edit the brief (default false). |
| webhook_url | https URL | Where to send events for this video. |
| metadata | object ≤ 1024 characters as JSON | Returned unchanged on the video. |
Product categories: general, beauty, fashion, tech, food, fitness, home, pet, kids, health. Service categories: beauty_wellness, food_beverage, property, interior_renovation, fitness, clinic_health, education, automotive, events_media, general_service.
A create the account cannot pay for gets 402 insufficient_credits. By default at most 5 API videos per account can be in progress at once (429 too_many_active_videos).
The video object
{
"id": "vid_9b2e...",
"status": "processing",
"progress": { "stage": "scene_creation", "percent": 40 },
"output": null,
"error": null,
"brief": null,
"review_expires_at": null,
"mode": "balance",
"credits_held": 10,
"credits_charged": 0,
"metadata": { "order_id": "8731" },
"created_at": "2026-11-02T08:15:04.000Z",
"completed_at": null
}| Field | Notes |
|---|---|
| id | String starting vid_. |
| status | One of the five values below. Branch on this. |
| progress | { stage, percent }. For display only: percent is approximate and stage is a descriptive label that can change, so do not branch on it. |
| output | null until completed; then video_url, thumbnail_url, duration_sec, aspect_ratio, quality, url_expires_at. |
| error | null unless failed; then { code, message } (and param when a field caused it). Show message to people; match on code in your code. |
| brief | null except while awaiting_review: the brief Roilo drafted: the same fields as product on create, plus business_name. |
| review_expires_at | null except while awaiting_review. |
| mode, credits_held, credits_charged | The mode you chose and the credits held and charged so far. |
| metadata | Whatever you sent on create, unchanged ({} if nothing). |
| created_at, completed_at | ISO 8601 UTC. completed_at is null until completed. |
| status | Meaning |
|---|---|
| queued | Accepted; inputs are being prepared. |
| awaiting_review | Paused for you (review: true). Approve it, or it continues on its own when review_expires_at passes. |
| processing | Generating. progress.percent goes 0 → 100. |
| completed | output is set. video_url and thumbnail_url are signed links that expire at url_expires_at (1 hour); fetch the video again for fresh links. |
| failed | error.code and error.message say why. Held credits are refunded. |
GET /v1/videos/:id reads one; GET /v1/videos?limit=20&cursor=… lists the videos on this account, newest first, as { "data": [...], "next_cursor": "..." | null } (limit is clamped to 1–100, default 20; pass next_cursor back as cursor for the next page, until it is null); DELETE /v1/videos/:id deletes one (204).
Upload a file
POST /v1/files returns 201 and takes one multipart part named file: JPEG, PNG or WebP, up to 50 MB, at most 20,000 px on an edge. The id is valid for 24 hours and can be used in more than one video in that time; up to 100 uploads per 24 hours.
curl -X POST https://roilo.co/api/v1/files \
-H "Authorization: Bearer rk_live_..." \
-F "[email protected]"
# -> { "id": "file_2f1c...", "expires_at": "2026-11-03T08:15:04.000Z" }Review before generation
Send review: true and the video pauses at awaiting_review once Roilo has read the photos. Fetch it to see the brief Roilo drafted, then approve it, with or without edits. If you do nothing, it continues on its own at review_expires_at (24 hours by default).
curl -X POST https://roilo.co/api/v1/videos/vid_9b2e.../approve \
-H "Authorization: Bearer rk_live_..." \
-H "Content-Type: application/json" \
-d '{ "brief": { "name": "Trail runner", "category": "fashion", "pain_point": "Wet trails, cold feet." } }'The body is optional. brief takes the same fields as product on create; send only the ones you want to change, or no body to approve as is. It returns 200 with the video object, now processing. Approving a video that is not waiting gets 409 not_awaiting_review.
Webhooks
Set webhook_url on a video and Roilo POSTs JSON to it for video.awaiting_review, video.completed and video.failed. The body carries the full video object:
{
"id": "evt_4a7d...",
"type": "video.completed",
"created_at": "2026-11-02T08:21:40.000Z",
"data": { "id": "vid_9b2e...", "status": "completed", "output": { "video_url": "https://...", "...": "..." } }
}Answer with any 2xx within 10 seconds. Redirects are not followed. On any other result Roilo makes six attempts in all, waiting about 1, 5, 15, 30 and 60 minutes between them. A URL that resolves to a private or local address is refused at once, with no retries. Events may arrive more than once; use the Roilo-Event-Id header (or id) to de-duplicate. The signed links inside a webhook body may have expired by the time a retry reaches you; fetch the video for fresh ones.
Every delivery is signed. The Roilo-Signature header is t=<unix seconds>,v1=<hex HMAC-SHA256> over `${t}.${rawBody}`, keyed by the key's webhook secret (whsec_…, shown once at creation; rotate it from the API keys page). Verify it like this, and reject anything older than 5 minutes:
import { createHmac, timingSafeEqual } from 'crypto';
// secret: the whsec_... shown once when the key was created (or rotated).
// rawBody: the request body bytes, exactly as received. header: the Roilo-Signature value.
export function verifyWebhook(secret, rawBody, header, now = Math.floor(Date.now() / 1000), toleranceSec = 300) {
const parts = new Map();
for (const piece of header.split(',')) {
const i = piece.indexOf('=');
if (i > 0) parts.set(piece.slice(0, i).trim(), piece.slice(i + 1).trim());
}
const t = Number(parts.get('t'));
const given = parts.get('v1');
if (!Number.isInteger(t) || !given) return false;
if (Math.abs(now - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(given, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}Lists and your account
All four lists return { "data": [ ... ] } with the fields below; they are not paginated. GET /v1/actors and /v1/voices include your own and the stock ones.
| Route | Returns |
|---|---|
| GET /v1/modes | id, name, credit_cost, service_credit_cost, requires_subscription, product_images {min,max}, actor_images {min,max}, venue_images {min,max}, default_quality, aspect_ratios, qualities |
| GET /v1/actors | id, name, preview_url, is_stock, gender, age_range |
| GET /v1/voices | id, name, preview_url, language, gender, is_own |
| GET /v1/caption-styles | id, name, aspect_ratio, is_default |
| GET /v1/me | user_id, key_id, key_name, credits {available}, key {month_spend, monthly_credit_limit} |
Errors
Every error is one shape; param names the field when there is one:
{ "error": { "code": "photo_count_out_of_range", "message": "...", "param": "product_images" } }Branch on the HTTP status and code; message is for people and its wording can change. Retry 429 and 5xx after a pause (reuse the same Idempotency-Key on create); fix the request on any other 4xx.
| Status | Codes |
|---|---|
| 400 | bad_request, upload_incomplete |
| 401 | invalid_api_key |
| 402 | insufficient_credits, key_credit_limit_reached, payment_required |
| 403 | subscription_required, storage_limit_reached, forbidden |
| 404 | not_found |
| 409 | request_in_progress, not_awaiting_review, project_delete_in_progress, conflict |
| 413 | file_too_large |
| 415 | multipart_required |
| 422 | validation_error, invalid_file_id, invalid_image_ref, invalid_image_url, invalid_webhook_url, unsupported_file_type, image_rejected, photo_count_out_of_range, invalid_brief, invalid_category, invalid_mode, actor_required, service_only_field, service_not_available, metadata_too_large, invalid_idempotency_key, idempotency_key_reused, invalid_cursor |
| 429 | rate_limited, too_many_active_videos, too_many_files |
| 503 | service_unavailable |
| 500 | internal_error |
Rate limits and idempotency
Limits are per key and per minute: 10 creates, 30 uploads, 120 of everything else (and 120 MCP calls). Each response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 rate_limited adds Retry-After.
Send an Idempotency-Key header (1–128 characters) on POST /v1/videos to make retries safe: for 24 hours the same key (per API key) returns the original response of a successful create, and the same key with a different body gets 422 idempotency_key_reused. While a create is still running, a second one on the same account gets 409 request_in_progress.
MCP server
AI assistants that speak the Model Context Protocol can drive Roilo directly. The server is at https://roilo.co/api/mcp (Streamable HTTP, stateless) and authenticates with the same key as a bearer token. Add it to your assistant's MCP configuration:
{
"mcpServers": {
"roilo": {
"type": "http",
"url": "https://roilo.co/api/mcp",
"headers": { "Authorization": "Bearer rk_live_..." }
}
}
}Tools: create_video, get_video, list_videos, approve_video, delete_video, upload_image (base64, up to about 16 MB; give larger files or links to create_video as https links), list_modes, list_actors, list_voices, get_credits. Each tool takes the same fields and returns the same objects as the REST route behind it, and a failed call returns the same error body as a tool error. upload_image adds one code of its own, 422 invalid_base64. Credits are charged exactly as they are over REST.
Terms
Using the API or the MCP server means you accept the API and MCP access terms, including responsibility for how your own customers use what you build. Questions: [email protected].