API
The Ochra API
The Ochra API lets your own servers and scripts do what you do on the website: generate images, video and audio, read your library, characters and projects, upload files and check your credits. Every request uses a key you make in Settings, acts as your account and is charged to your balance at the prices the website shows.
- REST and JSON over HTTPS
- Same prices as the website
- A monthly cap on every key
- An OpenAPI 3.1 description
What it is
A REST API over HTTPS that answers JSON, at https://www.ochra.io/api/v1. It runs the same tools as the website, on the same models, at the same prices, and what it makes lands in your library beside everything you make on the site.
It is for your own servers and scripts: a shop that makes product shots, a backend that turns a brief into a video, a nightly job. To use Ochra from an AI app such as Claude, add the Ochra connector in that app instead; it needs no key.
Get a key
- Sign in, open Settings → API keys and choose New key.
- Name it after what will use it, choose Read or Read and generate, set its monthly cap in credits and choose Make key.
- Copy the key when it is shown. It is shown once: keep it in your server’s secrets, never in code you commit.
- Every key has a cap: the most credits it may spend in any 30 days, from 10 to 100,000 (1,000 unless you choose). Your balance still has to cover each request. GET /api/v1/me shows a key its cap and what it has spent in the last 30 days.
- You can also limit a key to some kinds of media or some models, and lower or raise its cap later.
- Up to 10 keys at a time. Revoking one stops it on the next request; we email you whenever a key is made.
Authentication
Send the key on every request, in the Authorization header. Keys look like och_live_xxxxxxxx…; the samples below read yours from an environment variable, OCHRA_API_KEY.
Authorization: Bearer och_live_xxxxxxxx…Call the API from your server only. A key in a web page or an app is a key anyone can copy, so the API sends no CORS headers, and a request that carries an Origin header, as a browser's does, is refused with 403. Never put a key in a URL, and revoke one you think has leaked: it stops on the next request.
Your first request
Check the key: GET /me answers who the key acts as, your balance and its limits. It costs no credits.
curl https://www.ochra.io/api/v1/me \
-H "Authorization: Bearer $OCHRA_API_KEY"Price an image before you make it: the same request with ?price_only=true answers what it would cost and whether your balance covers it, and holds nothing.
curl -X POST "https://www.ochra.io/api/v1/images?price_only=true" \
-H "Authorization: Bearer $OCHRA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"prompt": "A lighthouse on a cliff at dawn, soft film photograph", "count": 1}'{
"tool_name": "by_prompt",
"total": 3,
"balance": 1240,
"affordable": true,
"shortfall": 0,
…
}Then make it. The credits are held and the task comes back at once, with 202.
curl -X POST https://www.ochra.io/api/v1/images \
-H "Authorization: Bearer $OCHRA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"prompt": "A lighthouse on a cliff at dawn, soft film photograph", "count": 1}'curl https://www.ochra.io/api/v1/tasks/TASK_ID \
-H "Authorization: Bearer $OCHRA_API_KEY"The same loop in JavaScript, with fetch, from a server:
// On your server (Node 18 or later) — never in a web page.
import { randomUUID } from 'node:crypto';
const API = 'https://www.ochra.io/api/v1';
const auth = { Authorization: `Bearer ${process.env.OCHRA_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(path, init = {}) {
for (;;) {
const response = await fetch(`${API}${path}`, { ...init, headers: { ...auth, ...init.headers } });
const body = await response.json();
// 429: a window is full and nothing was run. Wait as long as it says, then send the same request
// again (a generation keeps its Idempotency-Key, so it is never made twice).
if (response.status === 429) {
await sleep(Number(response.headers.get('Retry-After') ?? 5) * 1000);
continue;
}
if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.request_id})`);
return body;
}
}
// 1. Start it. The credits are held and the task comes back at once (202).
let task = await call('/images', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID() },
body: JSON.stringify({ prompt: 'A lighthouse on a cliff at dawn, soft film photograph', count: 1 }),
});
// 2. Poll until the task is finished: every 5 seconds at first, then every 15 (a video takes minutes).
// Every poll is a request, counted against the key's 60 a minute and 2,000 a day.
const finished = ['completed', 'partial', 'failed', 'cancelled', 'held_for_review'];
let wait = 5000;
while (!finished.includes(task.status)) {
await sleep(wait);
wait = Math.min(wait * 2, 15000);
task = await call(`/tasks/${task.id}`);
}
// 3. The files are in `outputs`; each `url` is signed and expires, so download it soon.
for (const output of task.outputs) console.log(output.kind, output.url);Idempotency
Every request that starts a generation, and every change to a project, needs an Idempotency-Key header: a UUID you make, new for each new request. If the answer is lost, send the same request again with the same key: you get the first task back (200, with Idempotency-Replayed: true) and are not charged twice.
A key you already used, sent with a different request, is refused with 409 conflict and charges nothing. A request refused before anything was held — a wrong field, too few credits — makes no task, so the same key can be sent again once you have fixed it.
Tasks and polling
Generation takes time, so it is a task. The request is checked and priced, the credits are held, and the answer is 202 with the task while it renders. Images usually take 10–60 seconds and videos 1–6 minutes.
{
"id": "8f0c6a52-0000-4000-8000-000000000001",
"status": "processing",
"tool_name": "by_prompt",
"progress": { "total": 1, "done": 0, "failed": 0 },
"outputs": [],
…
}Poll GET /tasks/{id} every 5 seconds or so, and every 15 for a video, until status is completed, partial, failed, cancelled or held_for_review. Every poll is a request, counted against the key's 60 a minute and 2,000 a day; to follow several tasks, read them together with GET /tasks/batch. A 429 means wait the Retry-After seconds and carry on: nothing was lost.
The files are in outputs, each with a signed url that expires, so download what you keep. You are charged only for what was delivered; anything that failed is refunded by itself.
Errors
Every error is the same JSON: an HTTP status, error.code, a sentence you can show, and sometimes meta with the details. Every answer carries X-Request-Id; include it when you write to us.
{
"status_code": 402,
"detail": "This request needs 29 credits, but this key has 4 of its 30-day limit of 1000 credits left (996 used). Raise the limit in Settings → API keys.",
"error": {
"code": "key_cap_reached",
"message": "This request needs 29 credits, but this key has 4 of its 30-day limit of 1000 credits left (996 used). Raise the limit in Settings → API keys.",
"meta": { "reason": "key_cap_reached", "cap_credits": 1000, "spent_credits": 996, "required_credits": 29 }
},
"request_id": "6f1c2a9e-0000-4000-8000-000000000002"
}400 bad_request
The request could not be read: a generation or project change sent without an
Idempotency-Keyheader, or with one that is not a UUID, or a body that is not valid JSON. Nothing was run.401 unauthenticated
No key, a key that is not recognised, or a revoked key. Check the Authorization header.
402 insufficient_credits
Your balance cannot cover this request. Nothing was held; top up and send it again.
402 key_cap_reached
This request would take the key past its cap.
meta.required_creditsis what the request needs,meta.cap_creditsthe cap andmeta.spent_creditswhat the key has used in the last 30 days. Send a smaller request, raise the cap in Settings → API keys, or use another key.403 forbidden · meta.reason: browser_origin
The request carried an
Originheader, as one from a web page does. Call the API from your server; never put a key in a web page.403 forbidden · meta.reason: scope
The key has Read access and the request would generate or change something.
403 forbidden · meta.reason: kind_not_allowed / model_not_allowed
The request is outside the limits set on the key: a kind of media or a model it may not use.
meta.allowed_modelslists what it may.403 forbidden · meta.reason: customer_key_on_mcp
A key was sent to the MCP endpoint. To use Ochra from an AI app, add the Ochra connector there instead.
404 not_found
No such thing in your account — or it belongs to another account, which looks the same.
409 conflict
The Idempotency-Key was already used for a different request. Nothing was charged; send a new key for a new request.
422 validation_error
A field is missing or wrong.
error.detailsnames each field and what to change.429 rate_limited
Too many requests in one of the windows below. Wait the
Retry-Afterseconds, then retry; nothing was run.500 internal_error
Something failed on our side. Retry later; if it keeps happening, send us the
request_id.503 maintenance
The site has reached its generation limit for the day, or is briefly down for maintenance. Nothing was charged. Try again after
Retry-Afterseconds; the daily limit resets at 00:00 UTC.
Limits
Every answer to a request with a key carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset for the window nearest to refusing you. A refused request is a 429 rate_limited with Retry-After in seconds, and spends nothing.
60 per minute · 2,000 per day
Per API key
Every authenticated request, per API key.
10 per minute
Per API key
Requests that create a generation task (POST /generations, /images, /videos, /photoshoots and the character endpoints), per API key.
300 per day
Per account (all its keys together)
Requests that create a generation task the account has the credits for, counted across every API key of one account together.
600 per hour
Per deployment (all customer keys together)
Requests that create a generation task the account has the credits for, counted across every customer’s API keys together.
30 per hour
Per API key
POST /assets/import (the server downloads a URL), per API key, whether or not the import succeeds.
30 per 5 minutes
Per client network (hashed; IPv6 by /64)
Failed authentications that reach the key table (an unknown or revoked key, or an owner who may not use it), per client network: the IPv4 address, or the /64 of an IPv6 one. A missing or malformed key is refused without being counted.
Prices
The API charges exactly what the website charges, in credits from the same balance, and the price of every request is known before it runs (?price_only=true, or POST /price). The current prices are on the pricing page, and GET /models?view=summary lists every model with what one image or clip costs.
Endpoints
Paths are under https://www.ochra.io/api/v1. Marked ones need a key with Read and generateaccess, and an Idempotency-Key where noted.
Your account
GET/api/v1/me
The key, its owner, balance and limits
GET/api/v1/openapi.json
This reference, for a machine
GET/api/v1/ledger
Your credit ledger, newest first
Models and prices
GET/api/v1/models
Tools and models you can generate with
GET/api/v1/models/{tool}
One tool: models, caps and input schema
GET/api/v1/models/{tool}/voices
The voices a text-to-speech model speaks with
POST/api/v1/price
Price a request before you run it
GET/api/v1/photoshoot-categories
Photo shoot categories
Generate
POST/api/v1/generations
Run any tool (raw tool call)Read and generate · Idempotency-Key
POST/api/v1/images
Generate images from a prompt, images or a characterRead and generate · Idempotency-Key
POST/api/v1/videos
Generate a video from a prompt, frames, references or a characterRead and generate · Idempotency-Key
POST/api/v1/photoshoots
A photo shoot of a person or a characterRead and generate · Idempotency-Key
Tasks
GET/api/v1/tasks
Your tasks, newest first
GET/api/v1/tasks/{id}
A task: status, calls, outputs and cost
GET/api/v1/tasks/batch
Several tasks by id, each in full
Your library
GET/api/v1/generations
Your outputs (the library), newest first
GET/api/v1/generations/{id}
One output with the prompt and parameters that made it
POST/api/v1/generations/delete
Delete generated files from your libraryRead and generate
GET/api/v1/media/{id}
One file by asset id, with its short links
GET/api/v1/media
Several files by asset id, as a gallery shows them
Characters
GET/api/v1/characters
Characters you own or that are shared with you
GET/api/v1/characters/{id}
A character: sheet, cover and reference photos
POST/api/v1/characters
Create a character, as the Character Generator doesRead and generate · Idempotency-Key
POST/api/v1/characters/{id}/cover
Make a character's cover portraitRead and generate · Idempotency-Key
POST/api/v1/characters/{id}/sheet
Rebuild a character's sheetRead and generate · Idempotency-Key
Projects
GET/api/v1/projects
Your projects, newest first
POST/api/v1/projects
Start a projectRead and generate · Idempotency-Key
GET/api/v1/projects/{id}
A project and what it holds
POST/api/v1/projects/{id}/rename
Rename a projectRead and generate · Idempotency-Key
POST/api/v1/projects/{id}/add
Put generated files, uploads and characters into a projectRead and generate · Idempotency-Key
POST/api/v1/projects/{id}/remove
Take things out of a projectRead and generate · Idempotency-Key
POST/api/v1/projects/{id}/delete
Delete a project (its contents are kept)Read and generate · Idempotency-Key
Your files
POST/api/v1/assets
Reserve an upload and get a URL to PUT the file toRead and generate
POST/api/v1/assets/{id}/confirm
Confirm an upload so it can be usedRead and generate
POST/api/v1/assets/import
Import a file from an https URLRead and generate
GET/api/v1/assets/{id}
One of your assets with a fresh URL
GET/api/v1/assets
Your own files (uploads and imports), newest first
POST/api/v1/assets/delete
Delete files you uploaded or importedRead and generate
The OpenAPI document
Every endpoint, field and error above, as an OpenAPI 3.1 document your tools can read — generated from the same contract the API checks requests with, so it is never behind it. It needs no key; with your key it describes exactly what that key may call. Send its ETag back as If-None-Match and an unchanged document answers 304.
Make your first key
Choose what it may do and how much it may spend, then copy it into your server's secrets.
