API REFERENCE
Video in, your JSON out.
Fovea is a video-to-JSON API. One endpoint. Send a video and the shape you want back, and you get JSON that conforms to that shape, or you get an error — never a best-effort object your code would happily consume.
30 FREE MINUTES
Per account, no card. Granted once — nothing renews.
10 MINUTES PER VIDEO
The cap at launch. Every job comes back on the request.
BILLED PER SECOND
Measured from your file, with a one-minute minimum.
01 / QUICKSTART
Sixty seconds
Create an account and generate a key in the dashboard. Keys look like fv_live_…. A new account starts with 30 free minutes and no card, which is enough to answer the only question that matters: does it work on your footage.
Give it to an agent
Fovea is a tool an agent reaches for, so this is the shortest path in. One line adds an extract tool your agent can call whenever it has a video and knows what it wants out of it.
claude mcp add --transport http fovea https://api.fovea.run/v1/mcp \ --header "Authorization: Bearer $FOVEA_KEY"
export FOVEA_KEY=fv_live_xxxxx codex mcp add fovea --url https://api.fovea.run/v1/mcp \ --bearer-token-env-var FOVEA_KEY
{
"mcpServers": {
"fovea": {
"type": "http",
"url": "https://api.fovea.run/v1/mcp",
"headers": { "Authorization": "Bearer ${FOVEA_KEY}" }
}
}
}Or call it yourself
A file, a schema, an Authorization header. There is no SDK, and there is nothing to install.
curl https://api.fovea.run/v1/extract \
-H "Authorization: Bearer $FOVEA_KEY" \
-F video=@standup-2026-08-04.mp4 \
-F schema='{ "decisions": [{ "at": "timestamp", "text": "string" }] }'import json, os, requests
schema = {"decisions": [{"at": "timestamp", "text": "string"}]}
response = requests.post(
"https://api.fovea.run/v1/extract",
headers={"Authorization": f"Bearer {os.environ['FOVEA_KEY']}"},
files={"video": open("standup-2026-08-04.mp4", "rb")},
data={"schema": json.dumps(schema)},
timeout=600,
)
response.raise_for_status()
for decision in response.json()["data"]["decisions"]:
print(decision["at"], decision["text"])import { openAsBlob } from "node:fs";
const form = new FormData();
form.set("video", await openAsBlob("standup-2026-08-04.mp4"));
form.set(
"schema",
JSON.stringify({ decisions: [{ at: "timestamp", text: "string" }] }),
);
const response = await fetch("https://api.fovea.run/v1/extract", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.FOVEA_KEY}` },
body: form,
});
const body = await response.json();
if (!response.ok) throw new Error(body.error.code);
console.log(body.data.decisions);What comes back:
{
"id": "b6f1c0f2-8a5e-4c3d-9f1a-2e7d4c8b0a31",
"data": {
"decisions": [
{ "at": "04:12", "text": "Ship the importer behind a flag" },
{ "at": "09:47", "text": "Postpone the pricing change to Q4" }
]
},
"video": { "duration_seconds": 90 },
"billed_seconds": 90
}The schema in those samples is not JSON Schema — it is a sketch, which is the shorter of the two forms we accept. See Schemas.
02 / AGENTS AND MCP
One tool, any client
The MCP endpoint is the same product as the REST endpoint — same key, same metering, same guarantees. It is a surface, not a separate service. Point any MCP client at it and the agent gets a tool called extract that it can choose a schema for on its own.
POST https://api.fovea.run/v1/mcp
The extract tool
| Argument | Required | What it is |
|---|---|---|
video_url | Yes | A URL we can fetch the video from. |
schema | Yes | The shape to return: JSON Schema, or a sketch of it. |
instructions | No | Free text alongside the schema — context, not a second contract. |
Tool calls carry JSON arguments rather than file bytes, which is why the tool takes a URL. An agent holding a local file should post it to /v1/extract as multipart instead.
The whole contract, exactly as a client receives it from tools/list. That call needs a key, so this is the only way to read it before you have an account — and it is generated from the same definitions the server registers, so it cannot describe a tool we do not serve.
{
"name": "extract",
"title": "Extract structured data from a video",
"description": "Analyse a video and return JSON matching a schema you supply. Accepts a full JSON Schema or a loose sketch of the shape. Videos up to 10 minutes. Billed per second of video with a one-minute minimum, against the account that owns the API key. The extraction comes back under `data`. Runs synchronously and takes roughly half a minute for a short clip, longer for a long one — if the call is cut off, use `recent_extractions` rather than calling this again.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"video_url": {
"type": "string",
"format": "uri",
"description": "A publicly reachable URL to the video to analyse."
},
"schema": {
"type": "string",
"description": "The shape you want back, as a JSON string. Either a JSON Schema, or a loose sketch such as {\"decisions\":[{\"at\":\"timestamp\",\"text\":\"string\"}]}. Any field named for a time comes back as a timecode."
},
"instructions": {
"description": "Optional extra context. It never overrides the schema.",
"type": "string",
"maxLength": 2000
}
},
"required": [
"video_url",
"schema"
],
"additionalProperties": false
},
"outputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"data": {
"description": "The extraction, matching the schema you supplied."
},
"job_id": {
"type": "string",
"description": "This extraction's id. Pass it to `recent_extractions` to read it again."
},
"billed_seconds": {
"type": "number",
"description": "Seconds charged, after the one-minute minimum."
},
"duration_seconds": {
"type": "number",
"description": "Measured length of the video."
}
},
"required": [
"data",
"job_id",
"billed_seconds",
"duration_seconds"
],
"additionalProperties": false
}
}What the agent sees
- The extraction JSON, already validated against whatever schema the agent asked for. There is no separate conformance step for it to do.
- Failures arrive as tool errors carrying the same stable
codethe REST endpoint returns, soout_of_minutesis something an agent can recognise and report rather than retry blindly. - Minutes are spent from the same balance as everything else. An agent with your key can spend your minutes — treat it accordingly.
03 / AUTHENTICATION
One bearer token
Authorization: Bearer fv_live_xxxxx
Create and revoke keys in the dashboard. We store a hash rather than the key, so a key is shown once, at creation — if you lose it, make another and revoke the old one. Every surface takes the same key: REST and MCP alike.
A missing, malformed or revoked key returns unauthorised. Keys are account credentials that spend real minutes, so they belong on a server or in your shell environment, never in a browser or a mobile app.
04 / THE EXTRACT ENDPOINT
POST /v1/extract
multipart/form-data. Send the video as a file or as a URL, and the schema as a JSON string.
| Field | Type | Notes |
|---|---|---|
video | File | The video itself. Send this or video_url, not both. |
video_url | String | A URL we can fetch instead of an upload. |
schema | String (JSON) | Required. JSON Schema, or a sketch of the shape you want. |
instructions | String | Optional free text — “ignore the intro”, “the speaker on the left is the customer”. Context for the model; the schema still governs what comes back. |
The response
| Field | Type | What it is |
|---|---|---|
id | String | The job. Read it again later at GET /v1/jobs/{id}. |
data | Object | Your JSON, in the shape you asked for. |
video.duration_seconds | Number | Measured from the file you sent, not from anything you told us. |
billed_seconds | Number | What this job cost. Never below 60. |
Conformance is guaranteed; judgement is not. Every answer is validated against your schema before it leaves the API. A malformed shape triggers an internal retry, and only if that retry also fails do we escalate to a larger model. If nothing conforms you get extraction_failed and no data. You are never handed a partial result.
Extraction happens while the request is open, so the JSON comes back on the same call. There is nothing to poll and no webhook to configure — at launch every video is under ten minutes, which is short enough to answer live.
05 / SCHEMAS
A sketch, or the real thing
Two forms are accepted and both are enforced identically. Whichever you send is the thing your answer is validated against — the instruction to the model and the guarantee to you are compiled from one object, so they cannot drift apart.
A sketch
Write the answer you want as though it were an example response, putting a type word where each value would go.
{
"title": "string",
"sentiment": "positive|neutral|negative",
"decisions": [
{
"at": "timestamp",
"text": "string — what was decided, in one sentence",
"owner": "string?"
}
],
"action_items": 0
}| What you write | What you get |
|---|---|
"string" | A string. "text" and "str" mean the same. |
"number" | A number. "float" and "decimal" mean the same. |
"integer" | A whole number. "int" means the same. |
"boolean" | True or false. "bool" means the same. |
"timestamp" | A point in the video as MM:SS. "time", "at", "when" and "clock" all do this. |
"a|b|c" | A string restricted to exactly those options. |
A trailing ? | Optional. Put it on the key ("owner?") or on the value ("string?"); both work. |
[ … ] | An array of whatever the first element describes. An empty array means a list of strings. |
{ … } | A nested object, as deep as you like. |
0 | A bare number is a type too: 0 asks for an integer, 0.0 for a number. |
| Anything else | A string, using what you wrote as its description. So "summary": "one paragraph on what happened" is a perfectly good sketch. |
You can describe any leaf while still naming its type by putting the description after a separator — "string — what was decided". An em dash, --, -, // or : all separate.
- Key order is kept. The order you write the keys in is the order they come back in — a sketch is read top to bottom, so the answer is too.
- Objects are strict. Every key is required unless you mark it optional, and nothing extra is ever added to the result.
A real JSON Schema
Send one and it passes through untouched. We never improve a contract you wrote deliberately.
{
"type": "object",
"properties": {
"decisions": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"properties": {
"at": { "type": "string", "pattern": "^\\d{1,2}:[0-5]\\d$" },
"text": { "type": "string" }
},
"required": ["at", "text"]
}
}
},
"required": ["decisions"]
}We tell the two apart by looking for JSON Schema’s own vocabulary at the root: $schema, properties, $defs, type, items, enum, anyOf, oneOf, allOf or $ref. Anything else is read as a sketch. The test is generous on purpose — misreading a real schema as a sketch would silently rewrite your contract, so we would rather err the other way.
Which means a sketch whose top level happens to be named properties, items, type or enum will be taken for a schema. Nest it one level deeper, or send real JSON Schema.
Which to use
- Sketch for almost everything, and for anything you are still working out. It is faster to write, faster to read in a diff, and it gives you timestamps for free.
- JSON Schema when you already have one — generated from your types, or shared with the rest of your stack — or when you need constraints a sketch cannot express:
minimum,maxItems,pattern,$ref.
06 / TIMESTAMPS
Any field can carry a time
This is the part a transcript-plus-LLM pipeline gets wrong. Ask for a moment and you get a real position in the video, measured from the start, in the same response as the data it belongs to.
{
"chapters": [
{ "start": "string", "end": "string", "heading": "string" }
],
"first_mention_of_pricing": "timestamp"
}In a sketch, a field becomes a timestamp two ways:
- The value says so.
"at": "timestamp", or any oftime,at,when,clock. - The key says so. A field named
at,time,timestamp,start,end,startsAt,endsAt,start_timeorend_timeis treated as a timestamp even when its value just says"string".
The format is MM:SS, and it is enforced by the same validator that enforces the rest of your shape rather than requested politely in a prompt. Videos an hour or longer would use HH:MM:SS; nothing is that long at launch.
A timestamp marks the moment the thing you asked about first becomes true, not the moment it finishes. If you want a span, ask for one: start and end are both timestamp keys.
Sending real JSON Schema puts you in charge of this, as it does everything else — nothing is injected into a schema you wrote. Timecodes still come back as MM:SS, so a "type": "string" field described as a moment works; add a pattern if you want it enforced.
07 / ERRORS
Nine codes, and they are stable
Every non-2xx response has one shape.
{
"error": {
"code": "video_too_long",
"message": "Videos are limited to 10 minutes for now.",
"details": []
}
}codeis the contract. Switch on it. It never changes meaning, and it never names or leaks the model underneath — you should not be able to tell which one ran, and you should not have to change your error handling on the day we swap it.messageis for a human reading a log. Do not parse it.detailsappears when there is something specific to say — most usefully onschema_invalidandextraction_failed, where it lists exactly which parts of your schema the answer failed to satisfy.
| Code | What happened | What to do |
|---|---|---|
unauthorised | The key is missing, malformed or revoked. | Check the Authorization header. Make a new key if you revoked that one. |
schema_missing | No schema field was sent. | Send one. It is the only required field besides the video. |
schema_invalid | The schema is not valid JSON, or is JSON Schema we cannot compile. | Read details — it says what broke. |
media_unreadable | The file is not video we can read, or the URL did not give us one. | Re-encode. H.264 in MP4 is always safe. |
video_too_long | Over the ten-minute cap. | Trim it, or split it and merge the results yourself. |
file_too_large | Over the upload ceiling for the path you used. | Re-encode at a lower bitrate. Resolution costs us nothing; bytes do. If you sent multipart, sending the raw body instead raises the ceiling to 2 GB. |
out_of_minutes | Your balance will not cover this video. | Nothing ran and nothing was charged. Jobs stop rather than overdraw. |
extraction_failed | No attempt produced an answer matching your schema. | Loosen the schema, mark speculative fields optional, or add instructions. You were not charged. |
provider_unavailable | The analysis service is temporarily unreachable. | Retry with backoff. This one is ours, not yours. |
Failures are free. A job is charged only once it has produced a conforming answer, so a run that ends in extraction_failed or provider_unavailable costs you nothing.
08 / LIMITS AND BILLING
What it costs, plainly
| Limit | Value | Why |
|---|---|---|
| Video length | 10 minutes | Extraction runs inside the request at launch, so a job's ceiling is what a request can survive. Longer files arrive with the queue. |
| Upload size | 2 GB, or 512 MB multipart | A multipart body is held whole in memory before we see any of it, so that path is lower. Send the video as the raw body, or as a video_url, for the higher ceiling. |
| Free minutes | 30 per account | No card. Granted once at signup and never renewed — a recurring free allowance with no card is farmable. |
| Response | Synchronous | Every job returns on the request it was made on. No job to poll, no webhook to register. |
How a job is priced
- Duration is probed from the file we received, with
ffprobe. Never from a field you send us — duration is the whole billing input, so a client-supplied one is a client-supplied invoice. - Billing is per second with a one-minute minimum per job. A 12-second clip costs a minute; a 90-second clip costs 90 seconds, not two minutes.
- Your balance is the sum of an append-only ledger, not a counter someone decrements. Every debit carries the job that caused it, so a balance can always be explained.
- A job that would overdraw refuses to start. You get
out_of_minutesbefore any model time is spent, not a surprise afterwards.
Top-ups
Prepaid minutes at $5, $25 and $100 — 20 minutes to the dollar, or $0.05 a video minute, carried over and never expiring — are coming soon and are not live. Nothing charges today. Pressing a price registers interest and says so. Free minutes are the only currency at launch.
09 / OTHER ENDPOINTS
The rest of it
| Endpoint | What it does |
|---|---|
GET /v1/jobs/{id} | One past extraction: its status, the JSON it returned, what it cost. Same key, same account. |
GET /v1/me | The account and the minutes left on it. Cheap enough to check before a batch. |
GET /v1/openapi.json | The OpenAPI document, generated from the same schemas the endpoint validates with — so it cannot drift from the API it describes. |
POST /v1/mcp | The MCP endpoint. Same key, same metering. |
10 / YOUR DATA
What we keep, and what we do not
- Videos are deleted when the job finishes. Success and failure alike, including failures we did not anticipate — deletion is in the path that always runs, not a line at the end of the happy one. The copy handed to the analysis service goes with it.
- Nothing you send to Fovea trains anything. Not your video, not your schema, not the result.
- The job row stays. Your schema, the JSON we returned, the duration and the charge. That is what
GET /v1/jobs/{id}reads, and what makes a line on your balance explainable.
Something here wrong, missing, or contradicted by what the API actually did? hello@fovea.run.