Octana is not a render API. If you have a timeline and want an MP4, a rendering service will be cheaper. Octana takes a topic and returns a finished, judged, publishable video — script, voice, storyboard, images, assembly, thumbnail and the quality gate that can refuse to ship it.
The API is included on Studio ($199/mo) and Agency ($499/mo), and calls are refused on the plans below them — including with a key minted earlier, so a downgrade stops the integration rather than quietly continuing to bill. Calls spend the same credits at the same rates as the app: there is no per-request fee and no API-only pack.
Create a key in Settings → API keys, tick the scopes it needs, and copy it. A key is shown once, at creation. Octana stores a hash and a short prefix, never the key itself, so a lost key is replaced rather than recovered.
Every example on this page is written against these two shell variables. Your base URL is your deployment's HTTP address: the Convex deployment URL with .convex.cloud swapped for .convex.site.
Do not put one in browser JavaScript. Every POST refuses browser preflight for exactly this reason: a key a browser can read is a key anyone reading the page can spend. The GET routes answer cross-origin requests, because a browser that reads a job's status with a key it should not have has still only read. Nothing that spends credits is reachable that way.
Scopes
A key carries the scopes you ticked when you created it, and nothing else. Adding a scope means creating a new key.
jobs:read
Read jobs
List jobs and read their status, stages and artifacts.
jobs:write
Create jobs
Launch videos. This spends credits.
tools:run
Run tools
Run any registered tool. This spends credits.
billing:read
Read billing
Balance, lots and ledger. Read-only.
Two shapes of call
POST /v1/videos returns immediately with a job id. A ten-minute video takes 8–20 minutes to finish; poll GET /v1/videos/{id} every 15 seconds.
POST /v1/tools/{id} blocks until the provider returns — seconds for a script, up to a minute for a batch of images.
The 15-second figure is what the rate limit below is sized for, not a measurement of what integrations need. It will be revised against real traffic rather than defended.
Endpoints
9 routes, and this page is all of them. There is no OpenAPI document, no client SDK and no command-line tool — every endpoint has a curl example instead. Click a block to select it.
POST
/v1/videos
jobs:writeCreate jobs
Start a video from a topic. Returns immediately with a job id; the work runs in the background.
topic and minutes are required. The optional fields are title, channelId, aspectRatio (16:9, 9:16, 4:5 or 1:1, default 16:9), reviewGate, angle, detailsContext, monetizationGoal, frameworks, visualStyleId, resolution and publish.
Unknown fields are rejected, not ignored. A misspelled field name is a 400 naming it, rather than a setting that reported success and did nothing.
The publish object has no madeForKids field, and sending one — true or false — is a 400. It is refused, not accepted and overridden.
estimatedCredits is the quote, not the charge. You are charged per stage as each stage completes, at the cost the provider actually reported, and a stage that fails refunds what it did not deliver.
queued: true means the account was at its concurrent-job ceiling and this job is holding position in the lane. It starts on its own; nothing else is needed from you.
GET
/v1/videos/{id}
jobs:readRead jobs
Read one job: where it is, what it has produced and what it has cost so far.
{
"id": "j98d4b1x7n2m5k8j0h3g",
"status": "refused",
"progress": 1,
"costCredits": 0,
"stages": [ … ],
"artifacts": [],
"judge": {
"score": 41,
"passed": false,
"threshold": 70,
"summary": "The hook restates the title and the middle third adds nothing.",
"issues": [
{ "severity": "blocker", "message": "The first eight seconds make no promise." }
]
},
"refusalReason": "The quality gate scored this 41 against a threshold of 70 and it was discarded. 247 credits were refunded."
}
status is one of queued, running, needs_review, awaiting_approval, completed, refused, failed or canceled. The stage names are the pipeline's own — script, voice, storyboard, entities, images, depth, motion, assemble, thumbnail, judge, publish — and which of them a job has depends on its brief.
Optional fields are omitted rather than sent as null. judge appears once the gate has scored the video; durationSec and publishedUrl once there is a finished file and a published URL; refusalReason only on a refusal.
A refused video costs nothing. costCredits reads back net of the refund and refusalReason states what came back, so you can prove from your own code that you were not charged. It lists no artifacts either: a discarded video's files stop being served, and a link that would 404 is a worse answer than saying there is nothing to download.
Artifacts carry an id and a kind, never a storage key. downloadUrl points at GET /v1/artifacts/{id} on this API, not at the file — the bytes sit behind that redirect because a storage URL in a response body is a credential in your logs that revoking your key would not revoke.
A job id that does not exist, one belonging to another account and a malformed one all return the same 404 with the same message. A different answer for each would let this endpoint be used to probe for other accounts' job ids.
GET
/v1/videos
jobs:readRead jobs
List jobs, newest first. Cursor-paginated; 25 per page by default, 100 at most.
nextCursor is null on the last page. Anything else, pass back as ?cursor= to continue. It is opaque: do not parse it, and do not build one yourself.
status filters to one value and accepts the stored statuses. A refused video is stored as canceled, so ?status=canceled is how you find one; the row still reads back with status: refused.
limit above 100 is clamped to 100 rather than refused.
GET
/v1/artifacts/{artifactId}
jobs:readRead jobs
Download one artifact. Redirects to a short-lived link to the file; follow it with curl -L.
HTTP/1.1 302 Found
location: https://…a link that expires in 15 minutes…
x-octana-api-version: 2026-08-01
The redirect target is valid for fifteen minutes and is not itself authenticated. Follow it and keep the bytes; do not store the link.
It serves both job artifacts and tool-run artifacts. Ownership is rechecked on every request, so revoking the key stops the next download — which is the property a link handed out in a response body would have thrown away.
jobs:read, even for a tool run's output. That is lossy in the same way GET /v1/me is: a key holding only tools:run can run a tool and read the text it returns, but needs jobs:read to fetch a file.
An artifact that does not exist, one belonging to someone else, and one belonging to a refused video all return the same 404.
POST
/v1/scripts
tools:runRun tools
Write a script without rendering anything. Synchronous — the response carries the text.
The Script Writer tool behind a named route, because it is the one most integrations reach for first. Same input, same output and same charge as POST /v1/tools/script-writer.
text is the script inline, so the common case needs no second request.
POST
/v1/batches
jobs:writeCreate jobs
Queue many videos from one brief. A batch is N jobs, so it needs no authority beyond the one that launches a single video.
defaultBrief is a full brief: minutes, aspectRatio and frameworks are required on it. Each row inherits it and may override topic, title, minutes, monetization, details and style.
Your plan's row cap applies. A batch above it is refused with the number that would have been queued, rather than silently truncated.
The whole batch is quoted and checked against your balance before any of it starts.
POST
/v1/tools/{toolId}
tools:runRun tools
Run any registered tool with the tool's own input. Synchronous — it blocks until the provider returns.
Request
curl -X POST "$OCTANA_API/tools/image-generator" \
-H "Authorization: Bearer $OCTANA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f2c1a70-images-01" \
-d '{
"prompt": "A cutaway of a cylindrical battery cell, technical illustration",
"count": 4,
"aspectRatio": "16:9"
}'
GET /v1/tools lists every id this route accepts, and each tool validates against its own input schema. An unknown id is a 400 that names where to find the real ones, not a 404.
Follow each downloadUrl with curl -L to fetch the bytes. Text artifacts carry their text inline instead and have no download link.
A tool switched off on this deployment is refused before anything is validated or charged.
It blocks for as long as the provider takes — seconds for text, up to about a minute for a batch of images. Set your client's timeout accordingly.
GET
/v1/tools
jobs:readRead jobs
The catalogue: every tool id POST /v1/tools/{toolId} will accept.
This is the one place the scope mapping over-grants. The channel list is not a billing figure, but creditBalance is, so a key that wants the channel list has to hold billing:read. Splitting this into two endpoints to avoid one over-grant would be a worse API than one honest scope.
Use channels[].id as the channelId on POST /v1/videos and POST /v1/batches.
Retrying a POST safely
Every POST accepts an Idempotency-Key header. Send one. It is not required — omit it and you get at-least-once semantics, which for POST /v1/videos means a client that times out and retries can end up with two videos and two charges.
A retry that arrives after the first request finished replays the stored response verbatim, with the same status code. It starts no second job and writes no second charge.
A retry that arrives while the first is still running gets 409 rather than starting a second job.
The same key with a different body — or against a different route — is 409 as well. Reusing a key for a different request is a client bug, and answering it with the first request's result would hide the bug instead of surfacing it.
Keys are scoped to the API key that sent them and expire after 24 hours. Two integrations on the same account can use the same value without colliding.
Rate limits
Limits are counted per key, not per account. A leaked or runaway key can be contained by revoking it without throttling the person who owns the account, and the dashboard keeps working while you do.
BucketCeilingBurst
Requests600 an hour120
Video launches20 an hour20
A 429 carries Retry-After: 60 and an error.message naming which ceiling was hit and when the next request can run. The header is a floor, not a countdown — the message is the precise answer.
Your account's own ceilings still apply on top of these, including the credit-burn rate and the video-launch limit the app is subject to. Money is limited per account; request rate is limited per credential. They answer different questions and both are checked.
Polling three jobs every ten seconds is about 1,080 requests an hour, which is more than the request ceiling admits. Poll every 15 seconds.
These numbers are a starting point sized against the poll interval above, not a measurement of what real integrations need. If they are wrong they will be changed — upwards is not a breaking change.
Errors
Every failure returns the same shape. The type is stable and safe to branch on; the message is an English sentence meant for whoever is reading the terminal.
Error shape
{
"error": {
"type": "invalid_request_error",
"message": "Number must be less than or equal to 30",
"param": "minutes",
"docsUrl": "https://your-app-host/docs/api"
}
}
param is present only when a specific field was at fault. docsUrl is on every error, including this page.
That API key is not valid or has been revoked. Create a new one at Settings → API keys.
403
permission_error
The key lacks the scope this route needs
This key does not have the "{scope}" scope. Create a key with it at Settings → API keys.
403
permission_error
The account's plan does not include the API
The public API is included on Studio and Agency. This account is on {plan}.
503
feature_disabled
The API is switched off on this deployment
The public API is not enabled on this deployment.
400
invalid_request_error
The body failed validation
The validation issue's own message, with the offending field in param.
400
invalid_request_error
The body was not JSON
That request body was not JSON.
400
invalid_request_error
The body was over 1 MB
That request body is larger than the 1024 KB limit. Split it into smaller requests.
400
invalid_request_error
Unknown tool id
Unknown tool "{id}". GET /v1/tools lists every available one.
404
not_found_error
No such route
No such endpoint. See {docsUrl}.
404
not_found_error
The job does not exist, or is not yours
No video with that id.
404
not_found_error
The artifact does not exist, is not yours, or belongs to a refused video
No artifact with that id.
409
conflict_error
The same Idempotency-Key is still in flight
A request with this Idempotency-Key is still running. Retry in a few seconds, or call GET /v1/videos to see whether it created a job.
409
conflict_error
The same Idempotency-Key with a different body
This Idempotency-Key was already used with a different request body.
429
rate_limit_error
A ceiling refused the request
The ceiling that was hit and when the next request can run, alongside a Retry-After header.
402
insufficient_credits
The balance cannot cover the estimate
This needs about {n} credits and {m} are available. Top up to continue.
500
server_error
Anything else, before anything was charged
Something failed on our side and it has been logged. Nothing was charged for this request.
500
server_error
Anything else, after the work had been paid for
Something failed on our side after the work had already been paid for. It has been logged, and the run's cost stands.
"No such key", "revoked key" and "expired key" return one identical sentence, and so do "no such job" and "not your job". An API that tells the three apart is an API that can be used to find out which keys and which jobs exist.
What a call costs
The API costs nothing to call and the same as the app to use. A video is charged per stage as each stage completes, at the cost the provider actually reported — not at the estimate. A tool run is charged once, when it returns. There is no per-request fee, no markup difference and no separate API credit pack.
Calls that run on your own provider keys charge zero platform credits, over the API exactly as in the app.
A video the quality gate refuses costs nothing. Discard it and every credit it consumed comes back; GET /v1/videos/{id} reports the refusal and the net cost, so you can prove it from your own code.
A failed stage refunds what it did not deliver. costCredits is always what you were actually charged, not what was quoted.
The estimate is arithmetic you can reproduce. It is driven by length and image density: length sets the word target and therefore the script, the narration characters and the runtime; the runtime divided by seconds-per-image sets the scene count, and the scene count is what the image spend scales with. Resolution multiplies the image and assembly rows only. Ten minutes at one image every eight seconds on the budget tier estimates 205 credits — the figure quoted in POST /v1/videos above, and the same number the Create page shows before you spend anything. The pricing page has the per-tier table.
There is no estimate endpoint. GET /v1/me returns the balance and POST /v1/videos returns the estimate in its 202, which answers the question without another route to keep forever.
Versioning
The version is in the path, and /v1 is frozen against breaking changes. Every response also carries X-Octana-Api-Version: 2026-08-01, a dated version behind the path version, so a behaviour change that broke nothing is still attributable in a support ticket.
Additive, and not breaking
New optional request fields. New response fields. New endpoints. New values on an enum this page describes as extensible. Write clients that ignore fields they do not know.
Breaking
Removing or renaming a field. Changing a type. Making an optional field required. Changing a status code. Changing an error type string.
What a breaking change looks like
A /v2 alongside /v1. /v1 then gets at least six months, carrying Deprecation: true and a Sunset date header for the whole notice period.
What this API does not do
Being specific about the edges is cheaper for both of us than finding them at integration time.
There is no render endpoint
You cannot post a timeline or an edit-decision list and get an MP4 back. Octana generates the material and then assembles it; supplying your own assets and asking for a composite is a different product, and services built for that will do it better and cheaper. The routes above take a topic, not a cut.
There is no callback
Nothing calls your URL when a job finishes. Poll GET /v1/videos/{id} every 15 seconds. Outbound webhooks are a separate piece of work and this page will say so when they exist.
There is no streaming or long-polling
Job status is a poll. A ten-minute job does not need a socket, and a socket would need its own authentication, its own limits and its own failure modes.
There is no SDK, CLI or OpenAPI document
9 routes and a curl example each. If you want a typed client today, generate one from the shapes on this page.
Videos are never marked as made for kids
The publish object has no such field. It cannot be set through this API, by any route, and a body carrying it is rejected rather than accepted and ignored.
A key cannot be read back
It is shown once, at creation, and only a hash is stored. Lose it and the fix is to revoke it and create another — which takes effect on the next request, with no deploy.