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.

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.
413input_too_largeThe file is over the upload limit. details.max_bytes says the limit.
413payload_too_largeThe JSON body is over 2 MB.
422input_too_longThe file is over the duration limit, or shorter than half a second. details.max_seconds says the limit.
422input_unsupportedThe file isn't a media file the API can read.
422upload_missingThe upload id exists but no file was put to its URL.
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 (.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.
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, 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:

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.
typestringnoOne 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.languagestringnoA BCP 47 hint for the spoken language, for example en or es.
options.placebooleannoLook up the recording place from the file's GPS. Default true.
options.consolidatebooleannoAlso write the consolidated document, one paragraph per shot. Priced per minute on top.
webhook_urlstringnoWhere to POST analysis.completed or analysis.failed. See Webhooks.
sourceobjectnoDescribes the original when the upload is a proxy. See Describing the original.
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", "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.

FieldTypeDescription
source.namestringThe original's file name.
source.bytesintegerThe original's size.
source.sha256stringSHA-256 of the original's bytes, as 64 hex characters.
source.quick_hashstringSHA-256 of the size as decimal text, then the first MiB, then the last MiB. 64 hex characters.
source.containerstringThe container or extension, for example mov.
source.created_atstringWhen 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.locationobjectlat, lon, optional altitude in meters and accuracy_m.
source.cameraobjectmake and model.
source.videoobjectcodec, width, height (before rotation), fps, bit_rate, rotation (0, 90, 180, or 270), hdr, color_transfer, color_primaries, pixel_format.
source.audioobject or nullcodec, 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.

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 and timings lists the stages.
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
analyzemedia (default)application/jsonThe sidecar. Save it as <name>.analyzemedia next to the original.
texttext/plainA plain-text rendering of the analysis, for a model to read.
jsonapplication/jsonThe 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:

HeaderValue
AnalyzeMedia-EventThe event type.
AnalyzeMedia-DeliveryThe delivery id.
AnalyzeMedia-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> over <t>.<raw body> with the key's webhook secret.
User-Agentanalyzemedia-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 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/keys/:id/webhook-secretReads a key's webhook secret.
POST /dashboard/keys/:id/webhook-secretRotates a key's webhook secret.
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 /dashboard/webhooksRecent webhook deliveries for the account.
POST /dashboard/webhooks/testSends 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/:idAdministration, for accounts with the admin role.
POST /webhooks/stripe, POST /webhooks/clerkInbound webhooks from Stripe and Clerk, verified by their signatures.