API REFERENCE
Video in, your JSON out.
One endpoint. Send a video and the shape you want back. 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"
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" }] }'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.
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 2 GB. | Re-encode at a lower bitrate. Resolution costs us nothing; bytes do. |
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 | Per file. |
| 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.