Developers
The Ojelio API
Version 1.1.0 · OpenAPI file
Ojelio's API makes videos from text and pictures, as the app does: the same options, the same checks, the same credits. A script sends the content, follows the video while it is made, and downloads the MP4. Webhooks tell your server when a video is ready, so it does not have to ask.
The API comes with the paid plans. Everything here is also in an OpenAPI file, which tools can read to make a client.
Start here
- Make a key. In Ojelio, open API in the sidebar (owners and admins): the Keys and webhooks page. Under Make a key, give it a name and press Make the key. The key (
ojk_and 40 characters) is shown once: keep it in your password manager or your server's secrets. It belongs to the workspace, not to you, and stops working when it is revoked or the workspace moves to the free plan. - Send it as a Bearer token with every request, to
https://ojelio.com/api/v1:
curl https://ojelio.com/api/v1/options \
-H "Authorization: Bearer ojk_..."
- Make a video. The example script does it end to end in one command.
Requests and answers are JSON (Content-Type: application/json). Times are ISO 8601 in UTC. Fields may be added to answers at any time, so ignore the ones you do not know; anything that would break a working script comes as /v2.
Making a video
POST /videos starts a video and answers at once, with the video queued (status 201). Making it takes from one to a few minutes.
curl https://ojelio.com/api/v1/videos \
-H "Authorization: Bearer ojk_..." \
-H "Idempotency-Key: 6f1c2a9e-0d4b-4f6e-9a0c-2b7d3e8f1a55" \
-H "Content-Type: application/json" \
-d '{
"title": "Why the sea is salty",
"rights_confirmed": true,
"blocks": [
{ "type": "text", "content": "Rain wears down rocks and carries their minerals to the sea." },
{ "type": "image", "url": "https://example.com/coast.jpg", "alt": "A rocky coast" },
{ "type": "text", "content": "Water leaves the sea as vapour, but the salt stays behind." }
]
}'
Only title and rights_confirmed are required, with blocks for anything with a voice or captions. Every other field left out takes the workspace's brand kit default; GET /options shows them.
| Field | Values | Notes |
|---|---|---|
title |
text, up to 120 characters | Required. |
rights_confirmed |
true |
Required: you have the rights to use this content (Terms of Service). |
blocks |
a list of { "type": "text", "content" } and { "type": "image", "url", "alt" } |
The content, in order. Up to 10 pictures, from public addresses, and 10,000 words. |
source_url |
an address | Where the content comes from; kept with the video. |
format |
vertical, square |
|
language |
en-US, pt-BR |
Of the narration and captions. They are written in it whatever the content's language. |
text |
adapted, exact |
adapted: a script written from the content for the length asked. exact: your text, read as it is; the length follows it. |
seconds |
30, 45, 60, 90 |
The length of an adapted video. Your plan sets the longest; GET /options lists yours. |
if_too_long |
error (default), adapt |
An exact text too long for your plan: refuse it (too_long), or make it adapted. |
voice |
true, false |
A narrator reads the script. |
captions |
true, false |
A video needs a voice or captions, unless it is a single image. |
visuals |
scenes, single_image |
scenes: a picture made for each part. single_image: yours, throughout. |
image_url |
an address | The picture of a single_image video: JPEG, PNG or WebP, up to 4 MB, at least 400 pixels a side. |
narrator |
an ID from GET /options |
Of the video's language. |
style |
an ID from GET /options |
The look of the scenes: a built-in style or one of the workspace's. |
music |
an ID from GET /options, or none |
|
character |
false |
Leaves the brand kit's character out of the scenes. |
logo |
true, false |
The workspace's logo, where the brand kit puts it (paid plans). Left out, the brand kit's choice. |
review_before_render |
true, false |
Stop once the script is written, until it is approved (below). |
If the workspace's brand kit locks the video options for members, it locks them for the API too: a request that sends text, visuals, voice or captions other than the brand kit's is refused with forbidden, naming them. GET /options says so with options_locked.
Likewise, when the brand kit puts the workspace's logo on every video, "logo": false is refused with forbidden; GET /options says so with logo_required. Asking for "logo": true when the workspace has no logo to show (none uploaded, turned off, or the free plan) is refused with invalid_request.
A key acts as a member: it can use the built-in styles and music, and the workspace's own, but not anyone's personal ones. Its videos are counted to the person who made the key, on the Usage page.
What it can use: GET /options
The values each field takes in this workspace, with the IDs of its styles and music, and what a video gets when it leaves a field out:
{
"object": "options",
"seconds": [30, 45, 60, 90],
"narrators": [{ "id": "brian", "name": "Brian", "language": "en-US", "gender": "male" }],
"styles": [{ "id": "illustration", "name": "illustration", "built_in": true }],
"music": [{ "id": "calm", "name": "Morning light", "built_in": true }],
"defaults": { "format": "vertical", "language": "en-US", "seconds": 45, "text": "adapted", "style": "illustration", "music": "none", "...": "..." },
"options_locked": false,
"logo_required": false
}
The narrators: for en-US, brian (Brian), andrew (Andrew), steffan (Steffan), ava (Ava), jenny (Jenny), serena (Serena); for pt-BR, macerio (Macerio), thalita (Thalita), antonio (Antonio).
Following a video
GET /videos/{id} answers with the video as it is now. Ask every few seconds, or let a webhook tell you.
{
"id": "q7jttqn8mt",
"object": "video",
"status": "completed",
"title": "Why the sea is salty",
"origin": "api",
"seconds": 34.3,
"steps": [
{ "name": "check", "status": "completed" },
{ "name": "script", "status": "completed" },
{ "name": "voice", "status": "completed" },
{ "name": "visuals", "status": "completed" },
{ "name": "render", "status": "completed" }
],
"error": null,
"credits": { "estimated": 15, "charged": 12, "on_hold": false },
"files": {
"video": "https://...",
"video_bytes": 2412544,
"poster": "https://...",
"captions_vtt": "https://...",
"captions_srt": "https://...",
"expires_at": "2026-10-10T15:04:05.000Z"
},
"app_url": "https://ojelio.com/videos/q7jttqn8mt"
}
status is one of:
| Status | Meaning |
|---|---|
queued |
Waiting to start. |
running |
Being made; steps shows which. A step a video does not need is skipped. |
review |
Its script waits for your review (review_before_render). |
completed |
Ready: files has its links. |
failed |
It could not be made; error says why. Its credits are all given back. |
canceled |
Stopped. |
removed |
Taken down after a copyright notice. |
files links to the MP4 (and gives its size, video_bytes), its poster and its captions (WebVTT and SRT) once the video is completed. The links are signed and last an hour (expires_at): store the file, not the link, and ask for the video again for fresh links. app_url is the video's page in the app, where it can be watched, edited and shared.
Review, approve and cancel
A video made with "review_before_render": true stops in review once its script is written, before the voice and pictures are paid for. Read and edit the script on its page (app_url), or let it go on as written:
curl -X POST https://ojelio.com/api/v1/videos/q7jttqn8mt/approve \
-H "Authorization: Bearer ojk_..."
POST /videos/{id}/cancel stops a video that is queued, running or in review. The steps already finished are charged. Both answer with the video, or conflict when it is not in a state that allows it.
Listing videos
GET /videos lists the workspace's videos, newest first, whether made in the app or with the API (origin says which). limit takes 1 to 50 (20 by default). When there are more, next_cursor is set: pass it as cursor for the next page.
curl "https://ojelio.com/api/v1/videos?limit=50&cursor=MjAyNi0x..." \
-H "Authorization: Bearer ojk_..."
Sending a request again
A network can lose an answer after the video was made. So that sending the request again does not make a second video, POST /videos needs an Idempotency-Key header: any text up to 200 characters, a new UUID for each video. Send the same request with the same key and the API answers 200 with the video it already made, instead of 201 with a new one. Keep the key until you have the answer.
Errors
An error answers with its HTTP status and a JSON body:
{
"error": {
"code": "invalid_request",
"message": "\"seconds\" must be one of: 30, 45, 60, 90.",
"field": "seconds"
}
}
code is stable: write your program against it. message is for people and may change. Some errors add fields: field names the field at fault; needed and available the credits; retry_after (seconds) or retry_at (a time) when to try again.
| Status | code |
When |
|---|---|---|
| 401 | unauthorized |
No key, or a key that is not valid or was revoked. |
| 402 | plan_required |
The workspace is on the free plan. |
| 402 | insufficient_credits |
Too few credits for the video (needed, available). |
| 403 | forbidden |
Locked video options (fields), a workspace paused over a disputed payment, a key's maker whose content is under review, or a removed video. |
| 404 | not_found |
No such video in the key's workspace. |
| 409 | conflict |
The video is not in a state that allows this (status). |
| 422 | invalid_request |
A field is missing or wrong (field), or the body is not a JSON object. |
| 422 | content_refused |
The content was refused under the Acceptable Use Policy. |
| 422 | too_long |
The content has too many words, or an exact text is too long for the plan (words, max). |
| 429 | rate_limited |
Too many requests (Retry-After, retry_after) or videos (retry_at). |
| 429 | limit_reached |
Too many failed videos today, or video creation paused after repeated refusals (retry_at). |
| 500 | internal |
Something went wrong on our side. Try again; write to us if it keeps happening. |
Limits
- Each key: 120 requests a minute. Over it,
rate_limitedwith aRetry-Afterheader in seconds: wait that long and send it again. - Each workspace: 200 videos an hour through the API (
rate_limited, withretry_at). - After many failed videos in a day, or content refused again and again, a workspace cannot make videos for a while (
limit_reached, withretry_at), in the app as well. - Content: 10,000 words and 10 pictures a video.
Credits
A video uses the workspace's credits, as in the app. When it starts, the most it can cost is held (credits.estimated); when it ends, only what it used is charged (credits.charged) and the rest is released. A video that fails is not charged. A canceled one is charged for the steps it finished. With too few credits for the hold, POST /videos answers insufficient_credits with needed and available. Owners and admins buy credits and see where they went on the Credits and Usage pages.
Webhooks
A webhook calls your server when one of the workspace's videos is ready, failed, or waits for its review. Add an endpoint on the Keys and webhooks page (API in the sidebar): a public https address and the events it wants. A workspace can have 5. Nothing is sent while the workspace is on the free plan.
Events
| Event | When |
|---|---|
video.completed |
A video is ready. data.video.files has its links. |
video.failed |
A video could not be made. data.video.error says why. |
video.review_ready |
A video's script waits for its review. |
Every video of the workspace counts, made in the app or with the API. Send a test on the page sends a webhook.test message.
The message
A POST with a JSON body. data.video is the video as GET /videos/{id} shows it when the message is sent, with fresh links:
{
"id": "4c0f7a52-9a0b-4c2f-8b84-3e2f1d6a7c10",
"type": "video.completed",
"created_at": "2026-10-10T14:03:58.000Z",
"data": {
"video": { "id": "q7jttqn8mt", "object": "video", "status": "completed", "...": "..." }
}
}
Answer with any 2xx status within 10 seconds; do slow work after answering. Redirects are not followed.
Checking a message
Anyone can send a request to your endpoint, so check that each message is Ojelio's before you trust it. Messages follow the Standard Webhooks specification, with three headers:
| Header | What it holds |
|---|---|
webhook-id |
The message's ID, the same as id in the body, and the same at every attempt. |
webhook-timestamp |
When it was signed, in seconds since 1970 (UTC). |
webhook-signature |
v1, and the signature, in base64. There may be several, separated by spaces. |
The signature is the HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed with the endpoint's secret: the bytes of the base64 after whsec_. Use the body exactly as it arrived, before parsing it. Refuse a message whose timestamp is more than five minutes from now, so an old one cannot be sent again by someone else.
The Standard Webhooks libraries do all of this. In Node.js (npm install standardwebhooks):
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.OJELIO_WEBHOOK_SECRET); // "whsec_..."
// rawBody: the body as text, exactly as it arrived; headers: the request's headers.
const message = webhook.verify(rawBody, headers); // throws when it is not Ojelio's
Or by hand, with nothing but Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromOjelio(headers, rawBody, secret) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
if (!id || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return String(headers["webhook-signature"]).split(" ").some((signature) => {
const given = Buffer.from(signature.split(",")[1] ?? "", "base64");
return signature.startsWith("v1,") && given.length === expected.length && timingSafeEqual(given, expected);
});
}
The secret is on the page (Show secret). New secret replaces it at once: the next message is signed with the new one only.
Retries
Any answer other than a 2xx, or none in 10 seconds, is tried again later: after 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours. That is 8 attempts over about a day. Each delivery, its attempts and your server's answers are on the page for 30 days, where Send again sends one once more.
A message can arrive more than once: when your answer was lost, or when it is sent again by hand. Keep the webhook-id of the messages you have handled and ignore repeats. Messages can also arrive out of order; created_at and the video's status say what is current.
Examples
make-video.mjs makes a video, follows it until it is ready, and saves the MP4. Node.js 20 or later, no packages. It uses a sample text, or a file of paragraphs:
OJELIO_API_KEY=ojk_... node make-video.mjs
OJELIO_API_KEY=ojk_... node make-video.mjs article.txt "The title"
// Makes a video with the Ojelio API, follows it until it is ready, and downloads it.
// Node.js 20 or later, no packages. https://ojelio.com/docs/api
//
// OJELIO_API_KEY=ojk_... node make-video.mjs
// OJELIO_API_KEY=ojk_... node make-video.mjs article.txt "The title"
//
// Without a file it uses a short sample text. Making a video uses your workspace's
// credits. OJELIO_API_URL points it at another server (it defaults to ojelio.com).
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
const BASE = process.env.OJELIO_API_URL ?? "https://ojelio.com/api/v1";
const KEY = process.env.OJELIO_API_KEY;
if (!KEY) {
console.error('Set OJELIO_API_KEY to a key from the "Keys and webhooks" page of Ojelio.');
process.exit(1);
}
const SAMPLE = `Rain wears down rocks and carries their minerals to the sea.
Water leaves the sea as vapour, but the salt stays behind. Over millions of years, the sea grew salty.`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** One call to the API. Waits and tries again when told to slow down; throws on any other error. */
async function api(method, path, body, headers = {}) {
for (;;) {
const response = await fetch(BASE + path, {
method,
headers: {
authorization: `Bearer ${KEY}`,
...(body ? { "content-type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await response.json();
if (response.ok) return json;
if (json.error?.code === "rate_limited") {
const seconds = Number(response.headers.get("retry-after") ?? 5);
console.log(` slowing down for ${seconds} s`);
await sleep(seconds * 1000);
continue;
}
const error = new Error(`${response.status} ${json.error?.code}: ${json.error?.message}`);
error.details = json.error;
throw error;
}
}
// 1. The content: a file of paragraphs, or the sample.
const [file, title = "Why the sea is salty"] = process.argv.slice(2);
const text = file ? await readFile(file, "utf8") : SAMPLE;
const blocks = text
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((content) => ({ type: "text", content }));
// 2. Make it. Fields left out take the workspace's defaults (GET /options shows them).
// The Idempotency-Key stays the same if this request is sent again, so a retry
// after a lost answer finds the same video instead of making a second one.
const idempotencyKey = randomUUID();
let video;
for (let attempt = 1; ; attempt++) {
try {
video = await api("POST", "/videos", { title, blocks, rights_confirmed: true }, { "idempotency-key": idempotencyKey });
break;
} catch (error) {
if (error.details || attempt === 3) throw error; // The API answered: sending it again will not help.
console.log(` no answer (${error.message}), trying again`);
await sleep(2000 * attempt);
}
}
console.log(`Made ${video.id}: "${video.title}", up to ${video.credits.estimated} credits`);
console.log(` ${video.app_url}`);
// 3. Follow it. A webhook (video.completed) saves the polling; see the docs.
let last = "";
while (["queued", "running", "review"].includes(video.status)) {
const step = video.steps.find((item) => item.status === "running")?.name;
const now = step ? `${video.status}: ${step}` : video.status;
if (now !== last) console.log(` ${now}`);
last = now;
if (video.status === "review") {
console.log(` Its script waits for your review: approve it on ${video.app_url}, or POST /videos/${video.id}/approve.`);
}
await sleep(5000);
video = await api("GET", `/videos/${video.id}`);
}
if (video.status !== "completed") {
console.error(`The video ended "${video.status}"${video.error ? `: ${video.error}` : ""}`);
process.exit(1);
}
console.log(`Ready: ${video.seconds} seconds, ${video.credits.charged} credits`);
// 4. Download it. The links last an hour; GET /videos/{id} gives fresh ones.
const mp4 = await fetch(video.files.video);
if (!mp4.ok) throw new Error(`The download answered ${mp4.status}`);
const name = `${video.id}.mp4`;
await writeFile(name, Buffer.from(await mp4.arrayBuffer()));
console.log(`Saved ${name}`);
receive-webhook.mjs receives webhooks, checks each one, and prints what happened:
OJELIO_WEBHOOK_SECRET=whsec_... node receive-webhook.mjs
// Receives Ojelio's webhooks and checks that each one is really from Ojelio.
// Node.js 20 or later, no packages. https://ojelio.com/docs/api#webhooks
//
// OJELIO_WEBHOOK_SECRET=whsec_... node receive-webhook.mjs
//
// It listens on port 8000 (or PORT). Ojelio sends only to a public https address,
// so put it behind your server or a tunnel while you try it.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.OJELIO_WEBHOOK_SECRET;
if (!SECRET?.startsWith("whsec_")) {
console.error('Set OJELIO_WEBHOOK_SECRET to the endpoint\'s secret ("Show secret" on the "Keys and webhooks" page).');
process.exit(1);
}
const KEY = Buffer.from(SECRET.slice("whsec_".length), "base64");
/** A message signed longer ago than this is refused, so an old one cannot be replayed. */
const TOLERANCE_SECONDS = 5 * 60;
/** Whether a message is Ojelio's: the signature of "<id>.<timestamp>.<body>", recent enough. */
function verify(headers, body) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
const signatures = String(headers["webhook-signature"] ?? "").split(" ");
if (!id || !Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", KEY).update(`${id}.${timestamp}.${body}`).digest();
// Several signatures can be sent, separated by spaces; one that matches is enough.
return signatures.some((signature) => {
const [version, value] = signature.split(",");
const given = Buffer.from(value ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}
// A message can arrive more than once (a retry after an answer that was lost):
// remember the IDs already handled. A real server keeps them in its database.
const handled = new Set();
createServer((request, response) => {
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", () => {
// Check the body exactly as it arrived, before parsing it.
const body = Buffer.concat(chunks).toString("utf8");
if (!verify(request.headers, body)) {
response.writeHead(401).end();
return;
}
const message = JSON.parse(body);
// Answer at once; do slow work afterwards (Ojelio waits 10 seconds at most).
response.writeHead(204).end();
if (handled.has(message.id)) return;
handled.add(message.id);
const video = message.data.video;
switch (message.type) {
case "video.completed":
console.log(`${video.id} is ready: ${video.files.video}`);
break;
case "video.failed":
console.log(`${video.id} failed: ${video.error}`);
break;
case "video.review_ready":
console.log(`${video.id} waits for its review: ${video.app_url}`);
break;
default:
console.log(`${message.type}: ${JSON.stringify(message.data)}`);
}
});
}).listen(Number(process.env.PORT ?? 8000), () => console.log(`Listening on port ${process.env.PORT ?? 8000}`));
Help
Write to [email protected] with the video's id, and the time and code of the error. The API's use is governed by the Terms of Service and the Acceptable Use Policy.