Skip to content
The /v1 API

Drive the studio from your own code

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.

Before the first call

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.

Setup
export OCTANA_API="https://your-deployment.convex.site/v1"
export OCTANA_KEY="oct_live_…"

API keys are server-side credentials

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.

Request
curl -X POST "$OCTANA_API/videos" \
  -H "Authorization: Bearer $OCTANA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f2c1a70-batteries-01" \
  -d '{
    "topic": "How lithium-ion batteries actually work",
    "minutes": 10,
    "aspectRatio": "16:9",
    "reviewGate": true
  }'
202 Accepted
{
  "id": "j57f2c9m4k1p8q3r6t0v",
  "status": "queued",
  "queued": true,
  "position": 2,
  "estimatedCredits": 205,
  "url": "https://your-app-host/dashboard/jobs/j57f2c9m4k1p8q3r6t0v"
}
  • 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.

Request
curl "$OCTANA_API/videos/j57f2c9m4k1p8q3r6t0v" \
  -H "Authorization: Bearer $OCTANA_KEY"
200 OK
{
  "id": "j57f2c9m4k1p8q3r6t0v",
  "status": "running",
  "progress": 0.62,
  "title": "How lithium-ion batteries actually work",
  "topic": "How lithium-ion batteries actually work",
  "createdAt": 1785110400000,
  "costCredits": 68,
  "stages": [
    { "stage": "script", "status": "ready", "attempts": 1,
      "startedAt": 1785110402000, "finishedAt": 1785110455000 },
    { "stage": "voice",      "status": "ready",   "attempts": 1 },
    { "stage": "storyboard", "status": "ready",   "attempts": 1 },
    { "stage": "images",     "status": "pending", "attempts": 0 }
  ],
  "artifacts": [
    { "id": "a3k7m1n5", "kind": "script", "text": "…", "downloadable": false },
    { "id": "a9p2c4v8", "kind": "audio", "downloadable": true,
      "downloadUrl": "https://your-deployment.convex.site/v1/artifacts/a9p2c4v8" }
  ],
  "error": null
}
200 OK — the quality gate refused it
{
  "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.

Request
curl "$OCTANA_API/videos?limit=20&status=completed" \
  -H "Authorization: Bearer $OCTANA_KEY"
200 OK
{
  "data": [
    {
      "id": "j57f2c9m4k1p8q3r6t0v",
      "status": "completed",
      "title": "How lithium-ion batteries actually work",
      "topic": "How lithium-ion batteries actually work",
      "progress": 1,
      "costCredits": 471,
      "createdAt": 1785110400000
    }
  ],
  "nextCursor": "c3Vydml2ZXM…"
}
  • 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.

Request
curl -L -o voiceover.mp3 "$OCTANA_API/artifacts/a9p2c4v8" \
  -H "Authorization: Bearer $OCTANA_KEY"
302 Found
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.

Request
curl -X POST "$OCTANA_API/scripts" \
  -H "Authorization: Bearer $OCTANA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f2c1a70-script-01" \
  -d '{
    "topic": "How lithium-ion batteries actually work",
    "minutes": 10,
    "frameworks": ["viral_hooks", "open_loops"]
  }'
200 OK
{
  "id": "t41k9p2c7m3n6b8v5x0z",
  "tool": "script-writer",
  "summary": "1,750 words",
  "costCredits": 3,
  "text": "…the script…",
  "artifacts": [
    { "id": "a5v8n2m6", "kind": "script", "text": "…the script…" }
  ]
}
  • 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.

Request
curl -X POST "$OCTANA_API/batches" \
  -H "Authorization: Bearer $OCTANA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f2c1a70-batch-01" \
  -d '{
    "name": "Battery series",
    "defaultBrief": {
      "minutes": 10,
      "aspectRatio": "16:9",
      "frameworks": ["viral_hooks"]
    },
    "rows": [
      { "topic": "How lithium-ion batteries actually work" },
      { "topic": "Why phone batteries lose capacity" }
    ]
  }'
202 Accepted
{
  "id": "b28c4v7n1m9k3j6h0g5f",
  "rows": 2,
  "estimatedCredits": 410,
  "url": "https://your-app-host/dashboard/jobs?batchId=b28c4v7n1m9k3j6h0g5f"
}
  • 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"
  }'
200 OK
{
  "id": "t72m5k8j1h4g7f0d3s6a",
  "tool": "image-generator",
  "summary": "4 images",
  "costCredits": 12,
  "artifacts": [
    { "id": "a1b2c3d4", "kind": "image",
      "downloadUrl": "https://your-deployment.convex.site/v1/artifacts/a1b2c3d4" },
    { "id": "a2b3c4d5", "kind": "image", "downloadUrl": "…" },
    { "id": "a3b4c5d6", "kind": "image", "downloadUrl": "…" },
    { "id": "a4b5c6d7", "kind": "image", "downloadUrl": "…" }
  ]
}
  • 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.

Request
curl "$OCTANA_API/tools" \
  -H "Authorization: Bearer $OCTANA_KEY"
200 OK
{
  "data": [
    { "id": "script-writer",   "label": "Script Writer",   "group": "Content" },
    { "id": "voiceover",       "label": "Voiceover",       "group": "Voice" },
    { "id": "image-generator", "label": "Image Generator", "group": "Visuals" },
    { "id": "thumbnail-maker", "label": "Thumbnail Maker", "group": "Visuals" }
  ]
}
  • A static catalogue read. It touches no billing figure, which is why it needs jobs:read and not billing:read.
  • Ids and labels only. Each tool's input fields are the ones its page in the app uses; this endpoint does not publish a schema.
GET

/v1/me

billing:readRead billing

The account behind the key: plan, credit balance, connected channels.

Request
curl "$OCTANA_API/me" \
  -H "Authorization: Bearer $OCTANA_KEY"
200 OK
{
  "plan": "studio",
  "planName": "Studio",
  "creditBalance": 21430,
  "channels": [
    { "id": "c19h4g7f0d3s6a9p2m5", "name": "How Things Work", "niche": "explainer" }
  ],
  "apiVersion": "2026-08-01"
}
  • 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.

StatusTypeWhenMessage
401authentication_errorNo Authorization headerMissing Authorization header. Send "Authorization: Bearer oct_live_…".
401authentication_errorBad, revoked or expired keyThat API key is not valid or has been revoked. Create a new one at Settings → API keys.
403permission_errorThe key lacks the scope this route needsThis key does not have the "{scope}" scope. Create a key with it at Settings → API keys.
403permission_errorThe account's plan does not include the APIThe public API is included on Studio and Agency. This account is on {plan}.
503feature_disabledThe API is switched off on this deploymentThe public API is not enabled on this deployment.
400invalid_request_errorThe body failed validationThe validation issue's own message, with the offending field in param.
400invalid_request_errorThe body was not JSONThat request body was not JSON.
400invalid_request_errorThe body was over 1 MBThat request body is larger than the 1024 KB limit. Split it into smaller requests.
400invalid_request_errorUnknown tool idUnknown tool "{id}". GET /v1/tools lists every available one.
404not_found_errorNo such routeNo such endpoint. See {docsUrl}.
404not_found_errorThe job does not exist, or is not yoursNo video with that id.
404not_found_errorThe artifact does not exist, is not yours, or belongs to a refused videoNo artifact with that id.
409conflict_errorThe same Idempotency-Key is still in flightA 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.
409conflict_errorThe same Idempotency-Key with a different bodyThis Idempotency-Key was already used with a different request body.
429rate_limit_errorA ceiling refused the requestThe ceiling that was hit and when the next request can run, alongside a Retry-After header.
402insufficient_creditsThe balance cannot cover the estimateThis needs about {n} credits and {m} are available. Top up to continue.
500server_errorAnything else, before anything was chargedSomething failed on our side and it has been logged. Nothing was charged for this request.
500server_errorAnything else, after the work had been paid forSomething 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.