Route reference
Every route of the AnalyzeMedia API. The base URL is https://api.analyzemedia.ai. Request and response bodies are JSON unless a route says otherwise. Field names in bodies use snake_case; the sidecar itself uses the format's own names.
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 sm_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), an optional monthly spend cap, and a webhook secret. 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 | input_too_large | The file is over the upload limit. details.max_bytes says the limit. |
| 413 | payload_too_large | The JSON body is over 2 MB. |
| 422 | input_too_long | The file is over the duration limit, or shorter than half a second. details.max_seconds says the limit. |
| 422 | input_unsupported | The file isn't a media file the API can read. |
| 422 | upload_missing | The upload id exists but no file was put to its URL. |
| 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 (.mp4 .mov .m4v .webm .mkv), audio (.mp3 .m4a .wav .flac .aac .ogg .opus .aif .caf), or image (.jpg .png .heic .webp .tif .gif .bmp .avif) extension. |
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, type }" }
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/quicktime" --data-binary @IMG_1731.MOV
Analyses
POST /v1/analyses
Starts a job. Requires analyses:write. The API probes the file, prices it, holds the estimate on your balance, and queues the job.
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. |
type | string | no | One of video/footage, video/edit, audio/speech, audio/music, image/scene, image/graphic. The API never infers the type from the content. Without a type, video files get video/edit, audio files audio/speech, and images image/scene. |
options.language | string | no | A BCP 47 hint for the spoken language, for example en or es. |
options.place | boolean | no | Look up the recording place from the file's GPS. Default true. |
options.consolidate | boolean | no | Also write the consolidated document, one paragraph per shot. Priced per minute on top. |
webhook_url | string | no | Where to POST analysis.completed or analysis.failed. See Webhooks. |
source | object | no | Describes the original when the upload is a proxy. See Describing the original. |
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", "type": "video/footage",
"options": {}, "cached": false,
"input": { "filename": "IMG_1731.MOV", "bytes": 61230411, "duration_s": 48.21, "width": 1080, "height": 1920, "sha256": "5c5e…" },
"estimate_cents": 20, "charge_cents": null, "timings": null, "error": null, "failed_stage": null,
"webhook_url": 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 file the API has already analyzed for your account as the same type returns with "cached": true and a charge of zero. A job that fails is never charged.
Describing the original
The analysis only needs a 720p copy. If the original is over the upload limit, upload a proxy and describe the original in source. Everything in source replaces what the API would read from the upload, and the sidecar describes the original. The cache key is the declared sha256.
| Field | Type | Description |
|---|---|---|
source.name | string | The original's file name. |
source.bytes | integer | The original's size. |
source.sha256 | string | SHA-256 of the original's bytes, as 64 hex characters. |
source.quick_hash | string | SHA-256 of the size as decimal text, then the first MiB, then the last MiB. 64 hex characters. |
source.container | string | The container or extension, for example mov. |
source.created_at | string | When the original was recorded, ISO 8601. Keep the camera's zone offset, for example 2026-08-25T14:05:42+08:00; the API uses it for local time and time of day. |
source.location | object | lat, lon, optional altitude in meters and accuracy_m. |
source.camera | object | make and model. |
source.video | object | codec, width, height (before rotation), fps, bit_rate, rotation (0, 90, 180, or 270), hdr, color_transfer, color_primaries, pixel_format. |
source.audio | object or null | codec, channels, sample_rate, bit_rate. null when the original has no audio. |
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, or give a webhook_url and wait.
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 and timings lists the stages. |
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 |
|---|---|---|
analyzemedia (default) | application/json | The sidecar. Save it as <name>.analyzemedia next to the original. |
text | text/plain | A plain-text rendering of the analysis, for a model to read. |
json | application/json | The full internal analysis. Larger than the sidecar and not the format; for debugging. |
The response is the file itself, not a JSON wrapper, with a Content-Disposition header carrying the file name. A job that isn't done returns 404 with the job's status in details. Results are kept until result_expires_at.
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: type (default video/edit), and either duration_s (seconds) or upload_id (the file is probed). Images need no duration. consolidate=true adds the consolidated document.
Response 200:
{ "duration_s": 213.69, "type": "video/footage", "options": { "consolidate": false },
"estimate_cents": 71, "balance_cents": 480,
"price": { "perMinuteCents": 20, "minimumCents": 20, "unit": "minute", "includes": "file facts, camera geometry, …" } }
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": "sm_live_a1b2", "mode": "live", "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" },
"prices": { "types": { "video/footage": { "perMinuteCents": 20, "minimumCents": 20, "unit": "minute", "includes": "…" }, … }, "consolidate_per_minute_cents": 5,
"unit": "cents per minute of video or audio, cents per image, with a minimum per job" } }
Webhooks
If you pass webhook_url when you start a job, the API POSTs to it once when the job ends. The body is the event with the job inside it:
{ "id": "wh_…", "type": "analysis.completed", "created_at": "2026-09-15T03:04:40Z",
"data": { "id": "an_…", "status": "done", "charge_cents": 71, "result": { "analyzemedia": "/v1/analyses/an_…/result?format=analyzemedia", … }, … } }
type is analysis.completed or analysis.failed. data has the same shape as GET /v1/analyses/:id.
Headers on every delivery:
| Header | Value |
|---|---|
AnalyzeMedia-Event | The event type. |
AnalyzeMedia-Delivery | The delivery id. |
AnalyzeMedia-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> over <t>.<raw body> with the key's webhook secret. |
User-Agent | analyzemedia-webhooks/1 |
To verify a delivery, compute HMAC-SHA256 of t followed by a dot and the raw body, with the key's webhook secret, and compare it to v1 in constant time. Reject deliveries whose t is more than five minutes old. The API waits 15 seconds for a 2xx; anything else is retried. You can read and rotate a key's webhook secret and send a test event from the platform.
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/keys/:id/webhook-secret | Reads a key's webhook secret. |
POST /dashboard/keys/:id/webhook-secret | Rotates a key's webhook secret. |
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 /dashboard/webhooks | Recent webhook deliveries for the account. |
POST /dashboard/webhooks/test | Sends a signed test event to a URL. |
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. |