Route reference

Every route of the Video Analysis API. The base URL is https://api.videoanalysisapi.com. Request and response bodies are JSON unless a route says otherwise. Field names in request and job bodies use snake_case; the result's own fields are described on the result page.

The routes under /v1 are the public API and take an API key. The routes under /dashboard and /admin serve the platform and take a signed-in session; they are listed at the end so that nothing is undocumented, but you don't call them from your own code.

Authentication

Every /v1 request carries an API key in the Authorization header:

Authorization: Bearer va_live_…

You create keys on the platform. A key has scopes (analyses:read, analyses:write, both by default), a rate limit per minute (60 by default), and an optional monthly spend cap. A revoked key returns 401.

Rate limits

Each key allows a fixed number of requests per minute. Every response includes X-RateLimit-Limit and X-RateLimit-Remaining. When the limit is reached, the API returns 429 with retry_after_seconds in the error details.

Errors

Every error has one shape:

{ "error": { "code": "insufficient_balance", "message": "This job needs $0.60 and the balance is $0.20. Buy credit to continue.", "details": { "needed_cents": 60, "balance_cents": 20 }, "request_id": "req_…" } }

code is stable and meant for programs. message is a sentence for a person. details is present when there is something to act on. Every response also carries the request id in the X-Request-Id header.

HTTPcodeWhen
400bad_requestThe request is malformed, for example an unknown format.
400validation_failedA body field fails validation. details names the fields.
401unauthorizedThe key is missing, unknown, or revoked.
402insufficient_balanceThe balance doesn't cover the job's estimate.
402spend_cap_reachedThe key's or the account's monthly cap is reached.
403forbiddenThe key lacks the scope, or a signed URL is invalid or expired.
404not_foundNo such job, upload, or result. A result that isn't ready yet is also 404, with the job's status in details.
409conflictThe job is running and can't be cancelled.
409idempotency_conflictThe idempotency key was used for a different request.
413payload_too_largeThe JSON body is over 2 MB.
404upload_missingNo upload has that id, or no file was put to its URL yet.
413input_too_largeThe file is over the upload limit. details.max_bytes says the limit.
422input_too_largeThe video's longest side is over the limit. details.max_dimension says the limit. Downscale it first.
422input_too_longThe video is over the duration limit. details.max_seconds says the limit. Split it first.
422input_unsupportedThe file isn't a video the API can read, has no video stream, or is shorter than half a second.
429rate_limitedThe key's per-minute limit is reached.
503provider_unavailableA model provider is down. Retry later. The job is not charged.
500internalSomething else failed. Quote the request_id when you report it.

Uploads

POST /v1/uploads

Declares a file and returns a signed URL to put its bytes to. Requires analyses:write.

Request body:

FieldTypeRequiredDescription
filenamestringyesThe file name, kept on the result. Must end in a video extension: .mp4 .mov .m4v .webm .mkv.
bytesintegeryesThe size in bytes. The upload URL rejects a larger body.
content_typestringnoDefaults to video/mp4.

Response 201:

{ "upload_id": "up_01m2hg7kdg729ye5at3feqfr4q",
  "upload": { "url": "https://…signed…", "method": "PUT", "headers": { "Content-Type": "video/mp4" }, "expiresAt": "2026-09-15T04:02:11Z" },
  "max_bytes": 314572800, "max_seconds": 600,
  "then": "POST /v1/analyses with { upload_id }" }

A bytes value over max_bytes returns 413 input_too_large without creating an upload. The URL is valid for one hour. An upload that never receives a file is discarded.

PUT the upload URL

Sends the file's bytes. This request goes to the signed URL from the previous response, not to the API host, and needs no API key. Set Content-Type to the file's type and send the bytes as the body:

curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @clip.mp4

Analyses

POST /v1/analyses

Starts a job. Requires analyses:write. The API probes the video, prices it, holds the estimate on your balance, and queues the job. There is nothing to choose: every job runs the whole analysis.

Request body:

FieldTypeRequiredDescription
upload_idstringone of upload_id or urlAn upload id whose file has been put.
urlstringone of upload_id or urlA public https URL to fetch instead of an upload.
options.languagestringnoA BCP 47 hint for the spoken language, for example en or es. Without it the language is detected.
idempotency_keystringnoRepeating a request with the same key returns the same job instead of starting a second one. A different request with the same key returns 409 idempotency_conflict.

Response 202 with the job, or 200 with "reused": true when the idempotency key matched an existing job:

{ "id": "an_01m2hg9r5x4d3v7t2q8k1n6p0c", "status": "queued",
  "options": {}, "cached": false,
  "input": { "filename": "clip.mp4", "bytes": 2812322, "duration_s": 13.467, "width": 720, "height": 1280 },
  "estimate_cents": 23, "charge_cents": null, "timings": null, "error": null, "failed_stage": null,
  "idempotency_key": null,
  "created_at": "2026-09-15T03:02:11Z", "started_at": null, "finished_at": null, "result_expires_at": null,
  "result": null, "reused": false }

A video the API has already analyzed for your account, with the same options, finishes with "cached": true and a charge of zero. A job that fails is never charged. input.sha256 appears once the job has run.

GET /v1/analyses

Lists your jobs, newest first. Requires analyses:read.

Query parameters: limit (1 to 100, default 25), before (a job id, for the next page), status (one of queued, running, done, failed, cancelled).

Response 200:

{ "data": [ { "id": "an_…", "status": "done", … } ], "next_before": "an_…" }

next_before is null on the last page. Pass it as before to get the next one.

GET /v1/analyses/:id

Returns the job. Requires analyses:read. The body has the same shape as the POST response. Poll it until status is done or failed. Polling is the only way to learn that a job has finished: the API does not call you back.

statusMeaning
queuedAccepted, waiting for a worker. The estimate is held.
runningA worker is analyzing the file.
doneThe result is ready. charge_cents is the final charge, timings lists the stages in seconds, and result holds the paths of the two formats.
failedThe analysis failed. error says why and failed_stage where. The hold is released.
cancelledYou cancelled it while it was queued. The hold is released.

GET /v1/analyses/:id/result

Returns the result. Requires analyses:read.

Query parameter format:

formatContent typeWhat you get
json (default)application/jsonThe breakdown: the result, with a signed link beside every crop it names.
texttext/plainThe same facts as a document to read, scene by scene.

The response is the result itself, not a JSON wrapper, with a Content-Disposition header carrying a file name. A job that isn't done returns 404 with the job's status in details. Results are kept until result_expires_at.

The crop links are signed and last an hour. Ask for the result again to get fresh ones; the crops themselves are kept as long as the result.

DELETE /v1/analyses/:id

Cancels a queued job, or deletes a finished job's result. Requires analyses:write.

Response 200: { "status": "cancelled" } for a queued job, { "status": "result_deleted" } for a done job, or { "status": "<the job's status>" } when there was nothing to do. A running job returns 409 conflict; it finishes and is charged.

GET /v1/estimate

Prices a job before you run it. Requires analyses:read.

Query parameters: either duration_s (seconds) or upload_id (the file is probed).

Response 200:

{ "duration_s": 13.467, "estimate_cents": 23, "balance_cents": 480,
  "price": { "perMinuteCents": 100, "minimumCents": 20, "unit": "minute", "includes": "the transcript with word times and speakers, …" } }

GET /v1/me

Returns the account behind the key. Requires analyses:read.

Response 200:

{ "account_id": "acct_…", "balance_cents": 480, "spend_cap_cents_month": null,
  "key": { "id": "key_…", "name": "laptop", "prefix": "va_live_a1b2", "scopes": ["analyses:read", "analyses:write"],
           "rate_limit_per_min": 60, "spend_cap_cents_month": null, "last_used_at": "2026-09-15T03:02:11Z", "revoked_at": null, "created_at": "2026-09-14T10:00:00Z" },
  "price": { "perMinuteCents": 100, "minimumCents": 20, "unit": "minute", "includes": "…" } }

Health

GET /health

No authentication. Returns { "ok": true, … } with the state of the database, the queue, the storage, and which providers are configured. Use it for uptime checks.

Platform routes

The following routes take a signed-in session from the platform, not an API key. The platform's pages are the only clients. They are listed here so that every route is documented.

Method and pathWhat it does
GET /dashboard/meThe signed-in account: balance, caps, email.
PATCH /dashboard/me/spend-capSets the account's monthly spend cap.
GET /dashboard/keysLists the account's API keys.
POST /dashboard/keysCreates a key. The full key is returned once.
PATCH /dashboard/keys/:idRenames a key or changes its rate limit or spend cap.
DELETE /dashboard/keys/:idRevokes a key.
GET /dashboard/jobsLists the account's jobs.
GET /dashboard/jobs/:idOne job.
GET /dashboard/jobs/:id/resultA job's result, with the same format parameter as the API.
GET /dashboard/ledgerThe credit ledger: holds, charges, releases, purchases.
GET /dashboard/usageSpend per day for the last days days.
POST /dashboard/billing/checkoutStarts a Stripe Checkout session for a credit pack.
GET /dashboard/billing/purchasesLists purchases.
GET /dashboard/billing/purchases/:id/receiptThe receipt URL for a purchase.
GET /admin/me, GET /admin/stats, GET /admin/accounts, GET /admin/accounts/:id, PATCH /admin/accounts/:id, POST /admin/accounts/:id/credit, GET /admin/purchases, GET /admin/jobs, GET /admin/jobs/:idAdministration, for accounts with the admin role.
POST /webhooks/stripe, POST /webhooks/clerkInbound webhooks from Stripe and Clerk, verified by their signatures.