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.
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | The request is malformed, for example an unknown format. |
| 400 | validation_failed | A body field fails validation. details names the fields. |
| 401 | unauthorized | The key is missing, unknown, or revoked. |
| 402 | insufficient_balance | The balance doesn't cover the job's estimate. |
| 402 | spend_cap_reached | The key's or the account's monthly cap is reached. |
| 403 | forbidden | The key lacks the scope, or a signed URL is invalid or expired. |
| 404 | not_found | No such job, upload, or result. A result that isn't ready yet is also 404, with the job's status in details. |
| 409 | conflict | The job is running and can't be cancelled. |
| 409 | idempotency_conflict | The idempotency key was used for a different request. |
| 413 | payload_too_large | The JSON body is over 2 MB. |
| 404 | upload_missing | No upload has that id, or no file was put to its URL yet. |
| 413 | input_too_large | The file is over the upload limit. details.max_bytes says the limit. |
| 422 | input_too_large | The video's longest side is over the limit. details.max_dimension says the limit. Downscale it first. |
| 422 | input_too_long | The video is over the duration limit. details.max_seconds says the limit. Split it first. |
| 422 | input_unsupported | The file isn't a video the API can read, has no video stream, or is shorter than half a second. |
| 429 | rate_limited | The key's per-minute limit is reached. |
| 503 | provider_unavailable | A model provider is down. Retry later. The job is not charged. |
| 500 | internal | Something 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:
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | yes | The file name, kept on the result. Must end in a video extension: .mp4 .mov .m4v .webm .mkv. |
bytes | integer | yes | The size in bytes. The upload URL rejects a larger body. |
content_type | string | no | Defaults 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:
| Field | Type | Required | Description |
|---|---|---|---|
upload_id | string | one of upload_id or url | An upload id whose file has been put. |
url | string | one of upload_id or url | A public https URL to fetch instead of an upload. |
options.language | string | no | A BCP 47 hint for the spoken language, for example en or es. Without it the language is detected. |
idempotency_key | string | no | Repeating 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.
status | Meaning |
|---|---|
queued | Accepted, waiting for a worker. The estimate is held. |
running | A worker is analyzing the file. |
done | The result is ready. charge_cents is the final charge, timings lists the stages in seconds, and result holds the paths of the two formats. |
failed | The analysis failed. error says why and failed_stage where. The hold is released. |
cancelled | You cancelled it while it was queued. The hold is released. |
GET /v1/analyses/:id/result
Returns the result. Requires analyses:read.
Query parameter format:
format | Content type | What you get |
|---|---|---|
json (default) | application/json | The breakdown: the result, with a signed link beside every crop it names. |
text | text/plain | The 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 path | What it does |
|---|---|
GET /dashboard/me | The signed-in account: balance, caps, email. |
PATCH /dashboard/me/spend-cap | Sets the account's monthly spend cap. |
GET /dashboard/keys | Lists the account's API keys. |
POST /dashboard/keys | Creates a key. The full key is returned once. |
PATCH /dashboard/keys/:id | Renames a key or changes its rate limit or spend cap. |
DELETE /dashboard/keys/:id | Revokes a key. |
GET /dashboard/jobs | Lists the account's jobs. |
GET /dashboard/jobs/:id | One job. |
GET /dashboard/jobs/:id/result | A job's result, with the same format parameter as the API. |
GET /dashboard/ledger | The credit ledger: holds, charges, releases, purchases. |
GET /dashboard/usage | Spend per day for the last days days. |
POST /dashboard/billing/checkout | Starts a Stripe Checkout session for a credit pack. |
GET /dashboard/billing/purchases | Lists purchases. |
GET /dashboard/billing/purchases/:id/receipt | The 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/:id | Administration, for accounts with the admin role. |
POST /webhooks/stripe, POST /webhooks/clerk | Inbound webhooks from Stripe and Clerk, verified by their signatures. |