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:

200 OK
{
  "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.

STREAMABLE HTTP
POST https://api.fovea.run/v1/mcp

The extract tool

ArgumentRequiredWhat it is
video_urlYesA URL we can fetch the video from.
schemaYesThe shape to return: JSON Schema, or a sketch of it.
instructionsNoFree 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 code the REST endpoint returns, so out_of_minutes is 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.

FieldTypeNotes
videoFileThe video itself. Send this or video_url, not both.
video_urlStringA URL we can fetch instead of an upload.
schemaString (JSON)Required. JSON Schema, or a sketch of the shape you want.
instructionsStringOptional 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

FieldTypeWhat it is
idStringThe job. Read it again later at GET /v1/jobs/{id}.
dataObjectYour JSON, in the shape you asked for.
video.duration_secondsNumberMeasured from the file you sent, not from anything you told us.
billed_secondsNumberWhat 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.

SKETCH
{
  "title": "string",
  "sentiment": "positive|neutral|negative",
  "decisions": [
    {
      "at": "timestamp",
      "text": "string — what was decided, in one sentence",
      "owner": "string?"
    }
  ],
  "action_items": 0
}
What you writeWhat 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.
0A bare number is a type too: 0 asks for an integer, 0.0 for a number.
Anything elseA 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.

JSON SCHEMA
{
  "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.

SKETCH
{
  "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 of time, at, when, clock.
  • The key says so. A field named at, time, timestamp, start, end, startsAt, endsAt, start_time or end_time is 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
{
  "error": {
    "code": "video_too_long",
    "message": "Videos are limited to 10 minutes for now.",
    "details": []
  }
}
  • code is 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.
  • message is for a human reading a log. Do not parse it.
  • details appears when there is something specific to say — most usefully on schema_invalid and extraction_failed, where it lists exactly which parts of your schema the answer failed to satisfy.
CodeWhat happenedWhat to do
unauthorisedThe key is missing, malformed or revoked.Check the Authorization header. Make a new key if you revoked that one.
schema_missingNo schema field was sent.Send one. It is the only required field besides the video.
schema_invalidThe schema is not valid JSON, or is JSON Schema we cannot compile.Read details — it says what broke.
media_unreadableThe 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_longOver the ten-minute cap.Trim it, or split it and merge the results yourself.
file_too_largeOver 2 GB.Re-encode at a lower bitrate. Resolution costs us nothing; bytes do.
out_of_minutesYour balance will not cover this video.Nothing ran and nothing was charged. Jobs stop rather than overdraw.
extraction_failedNo attempt produced an answer matching your schema.Loosen the schema, mark speculative fields optional, or add instructions. You were not charged.
provider_unavailableThe 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

LimitValueWhy
Video length10 minutesExtraction runs inside the request at launch, so a job's ceiling is what a request can survive. Longer files arrive with the queue.
Upload size2 GBPer file.
Free minutes30 per accountNo card. Granted once at signup and never renewed — a recurring free allowance with no card is farmable.
ResponseSynchronousEvery 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_minutes before 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

EndpointWhat it does
GET /v1/jobs/{id}One past extraction: its status, the JSON it returned, what it cost. Same key, same account.
GET /v1/meThe account and the minutes left on it. Cheap enough to check before a batch.
GET /v1/openapi.jsonThe OpenAPI document, generated from the same schemas the endpoint validates with — so it cannot drift from the API it describes.
POST /v1/mcpThe 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.