# FrameQuery - Full Product Information > AI-powered video search for professionals. Index your footage once, search it forever. ## What is FrameQuery? FrameQuery is a hybrid video analysis and search platform designed for video editors, content creators, marketing teams, and anyone managing large video libraries. It extracts frames and audio on your device and sends only those to the cloud for AI processing (transcription, scene detection, scene captions, and object detection), then stores the resulting index locally on your machine. Face recognition and voice recognition run entirely on-device. Your originals never leave your machine, and the extracted data is discarded the moment analysis completes. Once indexed, you can search across transcripts, scenes, objects, and faces in milliseconds - with no ongoing subscription required for search. ## Core Capabilities ### Ingest & Index - Watch-folder auto-scan picks up new files as they land - Blake3 hashing for smart deduplication - 50+ video and audio formats supported (R3D, BRAW, ProRes, H.264, MKV, and more via FFmpeg) - GPU-accelerated decoding (CUDA on Windows/Linux, Metal on macOS) - Roughly 5 minutes of processing per hour of video ### Search - Full-text transcript search with timestamps - Object detection with bounding boxes on every frame - Scene detection with shot type, angle, and caption - Face recognition to identify people across footage - Voice recognition and speaker diarisation - Results returned in milliseconds from local index - Search is free forever on all plans ### Analyse - Scene-level descriptions and summaries - Object detection with spatial data - Diarised audio (who said what, when) - Export to FCPXML, EDL, LosslessCut CSV for editor integration ### Review & Collaborate - Draw annotations directly on video frames - Timecoded comments for precise feedback - Approval tracking workflows - Shareable review links (reviewers don't need an account) ### Privacy & Ownership - Extracted frames and audio are discarded immediately after analysis - All data encrypted in transit and at rest - Index stored locally on your machine - Works offline after initial processing - Control how biometric data is shared in team settings - No third-party data sharing ## Pricing FrameQuery charges only for cloud processing. Search is always free. ### Desktop Plans | Plan | Price | Hours of video / month | |------|-------|------------------------| | Free | $0 | - | | Starter | $19/mo | 10 hrs of video/mo | | Pro | $45/mo billed annually ($54/mo monthly) | 50 hrs of video/mo | | Max | $190/mo billed annually ($228/mo monthly) | 300 hrs of video/mo | | Studio | Custom (book a call) | 300+ hrs of video/mo, bulk indexing | Hours are measured by the duration of your source video, not how long indexing takes. A 1-hour clip uses 1 hour of quota and typically finishes indexing in 5-10 minutes. All plans include: object detection, scene descriptions, transcription, all codecs & formats, face recognition, voice recognition, offline search, index sharing (local / Drive / NAS), and backup. Pro and Max add cloud-hosted sharing (with video previews and access management), timecoded comments, and approval workflows. Face & voice recognition run locally on previously processed videos, available on all plans. ### API Pricing US dollars per footage-hour, charged per completed stage (the API is in public beta): | Stage | Standard formats | Standard, above 500 h/month | Camera RAW (R3D, BRAW, ARRIRAW) | Camera RAW, above 500 h/month | |-------|------------------|-----------------------------|---------------------------------|-------------------------------| | Vision (scene captions) | $1.15 | $0.80 | $1.65 | $1.30 | | Transcript (per audio track) | $0.35 | $0.20 | $0.85 | $0.70 | | Both | $1.50 | $1.00 | $2.50 | $2.00 | Volume rates apply marginally to footage above 500 footage-hours in a UTC calendar month. Billed per second with a 30-second minimum per job; a stage that fails or is skipped is never charged. Prepaid credit packs of $10, $50 or $100 through Polar (1 credit = 1 US cent); enterprise accounts can pay as they go, billed monthly in arrears. Full details in the API documentation below. ## Supported Formats FrameQuery supports 50+ video and audio formats through FFmpeg, including professional formats like RED R3D, Nikon NEV, and BRAW (planned). See the full compatibility list at https://www.framequery.com/compatibility ## Technical Details - Desktop application (Windows, macOS, Linux) - Local SQLite-based search index - GPU-accelerated video decoding - REST API for programmatic access - Webhook support for automation ## Who Is It For? - **Video editors**: Search through hours of footage to find the right take - **Content creators**: Quickly locate clips across a growing library - **Marketing teams**: Find and repurpose existing video assets - **Post-production studios**: Collaborative review with frame-accurate feedback - **Archivists**: Make legacy footage searchable and discoverable ## FAQ **Is search free?** Yes. Searching footage you have already indexed is free, forever, including on the Free plan. Metadata search (filename, folder, codec, resolution) works on any video without indexing. AI analysis, meaning transcription, scene descriptions, object detection and search embeddings, uses the indexing hours on a paid plan. **What do you charge for?** Only cloud compute for video processing and cloud-hosted index sharing (video previews and access management). Sharing indexes via local export, Google Drive, Dropbox, or NAS is free on every plan. Search is local, so it's free. **Why so cheap?** We use techniques to reduce the data processed without hurting search quality, then pass those savings on. **Is my data secure?** Yes. Frames and audio are extracted on your device, sent for analysis, and discarded the moment analysis completes. Your originals never leave your machine. Everything is encrypted. No third-party data sharing. **Do I keep my data?** Yes. After processing, the metadata and search index are saved locally. Back it up and search it any time. **How long does processing take?** Roughly 5 minutes per hour of video. Transcription can take longer depending on length and queue depth. **Can I integrate with my NLE?** Yes. Export as FCPXML, Premiere XML, EDL, or LosslessCut CSV. There's also a public API. ## Links - Website: https://www.framequery.com - Blog: https://www.framequery.com/blog - API Documentation: https://www.framequery.com/docs - Compatibility: https://www.framequery.com/compatibility - Roadmap: https://www.framequery.com/roadmap - About: https://www.framequery.com/about - Download: https://www.framequery.com/welcome - Privacy Policy: https://www.framequery.com/privacy - Terms of Service: https://www.framequery.com/terms - Contact: support@framequery.com - GitHub: https://github.com/FrameQuery # FrameQuery API documentation > Every page of https://www.framequery.com/docs as Markdown, in reading order. Each page is also served on its own at the `Source` URL with `.md` appended. The machine-readable contract is https://api.framequery.com/v1/api/openapi.json; when a page and the contract disagree, the contract wins. --- # FrameQuery API > Send a video or audio file and get back timestamped scene captions and a speaker-labelled transcript. One asynchronous job, priced per footage-hour, charged only for stages that complete. Source: https://www.framequery.com/docs (updated 2026-10-13) The FrameQuery API runs the same server pipeline as the FrameQuery desktop app, behind a REST API. A job has up to two billable stages: - **Vision**: scene detection, plus a one-sentence caption for every scene with object labels, a colour palette, shot type and shot angle. - **Transcript**: speech-to-text with speaker labels (diarisation), as JSON or as an SRT, WebVTT or plain-text file. It accepts anything FFmpeg can decode, audio-only files (transcript only), and camera RAW (RED R3D, Blackmagic BRAW, ARRIRAW) for the formats [`GET /formats`](https://www.framequery.com/docs/formats) reports as enabled. You create a job, upload the file or give us a URL, and read the results once the job's `state` is `completed`, either by polling or from a signed webhook. > **Public beta.** The contract is versioned under `/v1` and changes are additive; see [Changelog and beta status](https://www.framequery.com/docs/changelog). The API does not do search, face recognition or NLE export. Those are desktop-app features. ## What you get back One scene from `GET /jobs/{jobId}/scenes`: ```json { "index": 3, "startTime": 41.2, "endTime": 48.9, "description": "A woman pours coffee at a kitchen table while a man reads a newspaper in morning light.", "objects": ["woman", "man", "coffee-pot", "mug", "newspaper", "table"], "colorPalette": ["#d8c3a5", "#8e6c4a", "#3b3a36", "#e9e4d8"], "shotType": "medium", "shotAngle": "eye-level" } ``` One segment from `GET /jobs/{jobId}/transcript`: ```json { "startTime": 41.6, "endTime": 44.1, "text": "Did you see the forecast for tomorrow?", "speaker": "Speaker 2" } ``` Times are seconds (float) from the start of the media. Every JSON key is camelCase. Full shapes: [Results](https://www.framequery.com/docs/results). ## Price $1.50 per footage-hour for vision plus transcript on standard formats: $1.15 for vision only, $0.35 for transcript only. Camera RAW costs $2.50 per footage-hour. Billing is per second with a 30-second minimum per job, and a stage that fails or is skipped is never charged. Above 500 footage-hours in a UTC calendar month the volume rates apply to the extra hours. You prepay with credit packs of $10, $50 or $100; enterprise accounts can pay as they go. See [Pricing and billing](https://www.framequery.com/docs/pricing). ## Start here - [Agent quickstart](https://www.framequery.com/docs/agent-quickstart): a short brief for an LLM agent writing code against the API. - [Quickstart](https://www.framequery.com/docs/quickstart): your first job with curl, Python or TypeScript. - [Authentication and keys](https://www.framequery.com/docs/authentication): get an `fq_live_` key and keep it server-side. - [Jobs](https://www.framequery.com/docs/jobs) and [Job lifecycle](https://www.framequery.com/docs/job-lifecycle): creating jobs, uploads, `state` and stages. - [API reference](https://www.framequery.com/docs/api): every endpoint with a copy-pasteable curl command. ## Machine-readable - OpenAPI 3.1, the contract every page here follows: [https://api.framequery.com/v1/api/openapi.json](https://api.framequery.com/v1/api/openapi.json). No key needed. - Keyless discovery: [`GET /pricing`](https://api.framequery.com/v1/api/pricing), [`GET /pricing/estimate`](https://api.framequery.com/v1/api/pricing/estimate?durationSeconds=3600&mode=all) and [`GET /formats`](https://api.framequery.com/v1/api/formats). - Every docs page is also plain Markdown: append `.md` to its URL, for example [/docs/webhooks.md](https://www.framequery.com/docs/webhooks.md). The index is [/docs.md](https://www.framequery.com/docs.md); [/llms.txt](https://www.framequery.com/llms.txt) lists every page and [/llms-full.txt](https://www.framequery.com/llms-full.txt) holds them all in one file. ## Base URL and conventions ```text https://api.framequery.com/v1/api ``` - Authenticate with `Authorization: Bearer $FRAMEQUERY_API_KEY` (or `X-API-Key`). Keys start with `fq_live_`. - Success responses are `{"data": …, "requestId": "…"}`. Errors are `{"error": "…", "code": "…", "docUrl": "…", "param": "…", "requestId": "…"}`; branch on `code`, never on the message. See [Errors](https://www.framequery.com/docs/errors). - Every response carries `X-Request-Id`. Quote it (or `requestId`) when you contact support. - Job creation accepts an `Idempotency-Key` so retries never create a second job. See [Jobs](https://www.framequery.com/docs/jobs#idempotency). --- # Agent quickstart > The brief for an LLM agent writing code against the FrameQuery API. Auth, the three calls, exact JSON shapes and which errors to retry. Source: https://www.framequery.com/docs/agent-quickstart (updated 2026-10-13) ## The brief Base `https://api.framequery.com/v1/api`. Header `Authorization: Bearer $FRAMEQUERY_API_KEY` (keys start `fq_live_`, server-side only). Success `{"data": …}`; error `{"error", "code", "docUrl", "param"}`: branch on `code`. 1. **Create.** `POST /jobs/from-url`, header `Idempotency-Key: `, body `{"url": "https://…/a.mp4", "processingMode": "all"}` (`all` | `transcript` | `vision`) → `data.jobId`. Local file: `POST /jobs` `{"fileName": "a.mp4", "fileSizeBytes": 1048576}`, then, within an hour, `PUT` the bytes to `data.uploadUrl` sending every `data.uploadHeaders` entry and no `Authorization`. The URL accepts exactly one object (a `412` means it already landed; read the job rather than re-uploading). 2. **Wait.** `GET /jobs/{jobId}?include=none` every 5–15 s until `data.state` is `completed`, `failed`, `cancelled` or `expired` (ignore `status`). Or pass `"callbackUrl"` at create for a signed webhook. 3. **Read.** `GET /jobs/{jobId}/transcript` → `data.text`, `data.speakers[]`, `data.segments[{startTime, endTime, text, speaker}]`; `?format=srt|vtt|txt` for a file. `GET /jobs/{jobId}/scenes` → `data.scenes[{index, startTime, endTime, description, objects[], colorPalette[], shotType, shotAngle}]`. Times in seconds. **Errors.** Retry 429, 503 and 409 `IDEMPOTENCY_IN_PROGRESS` after `Retry-After`; retry 500 with exponential backoff. Reuse the same `Idempotency-Key` on every retry. 409 `RESULTS_NOT_READY`: later. 404 `RESULTS_UNAVAILABLE`: never (see `reason`). 402: out of credits. Others: fix the request. `state` `failed` → `data.error` `{code, message, stage}`; completed stages stay readable. ## Copy-paste script The brief above as a runnable bash script (needs `curl` and `jq`): ```bash set -euo pipefail API=https://api.framequery.com/v1/api AUTH="Authorization: Bearer $FRAMEQUERY_API_KEY" JOB_ID=$(curl -sS -X POST "$API/jobs/from-url" \ -H "$AUTH" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"url": "https://example.com/interview.mp4", "processingMode": "all"}' | jq -r .data.jobId) while :; do STATE=$(curl -sS "$API/jobs/$JOB_ID?include=none" -H "$AUTH" | jq -r .data.state) echo "state: $STATE" case "$STATE" in completed|failed|cancelled|expired) break ;; esac sleep 10 done curl -sS "$API/jobs/$JOB_ID/transcript" -H "$AUTH" | jq '.data.segments[:3]' curl -sS "$API/jobs/$JOB_ID/scenes" -H "$AUTH" | jq '.data.scenes[:3]' ``` ## With an SDK The Python and TypeScript SDKs do the create, upload, wait and fetch in one call, with idempotency keys and retries built in: ```python from framequery import FrameQuery fq = FrameQuery() # reads FRAMEQUERY_API_KEY result = fq.process("https://example.com/interview.mp4") # or a local path print(result.transcript.text if result.transcript else "no transcript") for scene in result.scenes or []: print(scene.start_time, scene.description) ``` ```typescript import FrameQuery from "framequery"; const fq = new FrameQuery(); // reads FRAMEQUERY_API_KEY const result = await fq.process("https://example.com/interview.mp4"); // or a local path console.log(result.transcript?.text ?? "no transcript"); for (const scene of result.scenes ?? []) console.log(scene.startTime, scene.description); ``` ## Rules of thumb - Decide everything from `state` and `stages.*.status`. The `status` string is a legacy internal value and changes without notice. - Poll no faster than every 5 seconds; `estimatedCompletionTimeSeconds` on the job is a good hint. GET requests are limited to 600 per minute per account. - Send an `Idempotency-Key` on every create and reuse it on retries. A replay returns the original job with `Idempotent-Replayed: true`. - A failed or skipped stage costs nothing. A job whose vision stage failed can still have a completed, readable (and charged) transcript. - Never ship the key to a browser or mobile app. Proxy through your server. - Cost per footage-hour: $1.50 `all`, $1.15 `vision`, $0.35 `transcript` (per audio track); camera RAW $2.50 / $1.65 / $0.85. Per second, 30 s minimum per job. Check before spending with `GET /pricing/estimate?durationSeconds=600&mode=all` (no key) and `GET /quota` (`data.api.availableCents`). - More detail: [Jobs](https://www.framequery.com/docs/jobs), [Job lifecycle](https://www.framequery.com/docs/job-lifecycle), [Results](https://www.framequery.com/docs/results), [Errors](https://www.framequery.com/docs/errors), [OpenAPI](https://api.framequery.com/v1/api/openapi.json). --- # Quickstart > Process your first video in a few minutes. Get a key, create a job from a URL or a local file, wait for it, and read the transcript and scenes. Source: https://www.framequery.com/docs/quickstart (updated 2026-10-13) ## 1. Get a key During the public beta, [request access by email](mailto:support@framequery.com?subject=FrameQuery%20API%20beta%20access) first. Once your account is on, buy a credit pack on the [API keys page](https://www.framequery.com/profile/api-keys) (packs start at $10). Polar, our billing provider, issues your key with the purchase; it starts with `fq_live_`. Put it in an environment variable on the machine that will call the API: ```bash export FRAMEQUERY_API_KEY="fq_live_..." ``` Check it works. `GET /quota` returns your balance: ```bash curl https://api.framequery.com/v1/api/quota \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ```json { "data": { "api": { "available": true, "balanceCents": 1000, "heldCents": 0, "availableCents": 1000, "payAsYouGo": false, "monthUsageSeconds": 0, "tier": "standard", "volumeThresholdSeconds": 1800000 } }, "requestId": "4b0c2f8e9a1d4c67" } ``` ## 2. Install an SDK (optional) Everything below works with plain HTTP. The SDKs add uploads, polling, retries and typed results: ```bash # Nothing to install. The examples use curl and jq. ``` ```bash pip install framequery ``` ```bash npm install framequery ``` ## 3. Process a video from a URL Any public http(s) URL works, including presigned S3, R2, B2 or Google Cloud Storage links. ```bash curl -X POST https://api.framequery.com/v1/api/jobs/from-url \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "url": "https://example.com/interview.mp4", "processingMode": "all", "metadata": { "assetId": "A003_C008" } }' ``` ```python from framequery import FrameQuery fq = FrameQuery() # reads FRAMEQUERY_API_KEY result = fq.process("https://example.com/interview.mp4", metadata={"assetId": "A003_C008"}) print(result.job.state) # "completed" print(result.transcript.text if result.transcript else "") for scene in result.scenes or []: print(f"{scene.start_time:.1f}s {scene.description}") ``` ```typescript import FrameQuery from "framequery"; const fq = new FrameQuery(); // reads FRAMEQUERY_API_KEY const result = await fq.process("https://example.com/interview.mp4", { metadata: { assetId: "A003_C008" }, }); console.log(result.job.state); // "completed" console.log(result.transcript?.text ?? ""); for (const scene of result.scenes ?? []) console.log(`${scene.startTime.toFixed(1)}s ${scene.description}`); ``` The curl call returns the full Job object straight away, in state `queued`: ```json { "data": { "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "state": "queued", "processingMode": "all", "format": { "tier": "standard", "extension": "mp4" }, "stages": { "ingest": { "status": "pending" }, "vision": { "status": "pending" }, "transcript": { "status": "pending" } }, "metadata": { "assetId": "A003_C008" }, "links": { "self": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "transcript": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/transcript", "scenes": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/scenes", "cancel": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/cancel" } }, "requestId": "9f2a61c0b7e34d18" } ``` ## 4. Or upload a local file `POST /jobs` creates the job and returns a signed upload URL. Upload straight to it; the bytes never pass through our API servers. Send every header in `uploadHeaders` exactly as given (they are part of the signature) and do not send your API key to the upload URL. ```bash FILE=interview.mp4 RESPONSE=$(curl -sS -X POST https://api.framequery.com/v1/api/jobs \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d "{\"fileName\": \"$FILE\", \"fileSizeBytes\": $(wc -c < "$FILE")}") JOB_ID=$(echo "$RESPONSE" | jq -r .data.jobId) UPLOAD_URL=$(echo "$RESPONSE" | jq -r .data.uploadUrl) # Turn uploadHeaders into -H arguments and PUT the file. HEADERS=() while IFS= read -r h; do HEADERS+=(-H "$h"); done \ < <(echo "$RESPONSE" | jq -r '.data.uploadHeaders | to_entries[] | "\(.key): \(.value)"') curl -sS -X PUT "$UPLOAD_URL" "${HEADERS[@]}" --upload-file "$FILE" ``` ```python job = fq.jobs.create("interview.mp4") # creates the job and streams the upload print(job.job_id, job.state) ``` ```typescript const job = await fq.jobs.create("./interview.mp4"); // creates the job and streams the upload console.log(job.jobId, job.state); ``` Processing starts as soon as the upload completes. For files over a few hundred megabytes use the resumable upload described in [Jobs](https://www.framequery.com/docs/jobs#upload); the SDKs switch to it automatically above 32 MiB. ## 5. Wait for the job Poll the job and branch on `state`. `include=none` keeps the response small. ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID?include=none" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ```python job = fq.jobs.wait(job.job_id, timeout=3600) # returns the job in a terminal state print(job.state, job.stages.vision.status, job.stages.transcript.status) ``` ```typescript const done = await fq.jobs.wait(job.jobId, { timeout: 3_600_000 }); // ms in TypeScript console.log(done.state, done.stages.vision.status, done.stages.transcript.status); ``` The job moves from `queued` (or `awaiting_upload`) to `processing`, then to one terminal state: `completed`, `failed`, `cancelled` or `expired`. Rather than polling, you can pass a `callbackUrl` when you create the job and receive a signed [webhook](https://www.framequery.com/docs/webhooks). ## 6. Read the results ```bash # Transcript as JSON, then as an SRT file curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/transcript" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/transcript?format=srt" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" -o interview.srt # Scenes with captions curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/scenes" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ```python transcript = fq.jobs.transcript(job.job_id) for seg in transcript.segments: print(f"[{seg.start_time:.1f}-{seg.end_time:.1f}] {seg.speaker}: {seg.text}") srt = fq.jobs.transcript(job.job_id, format="srt") # str scenes = fq.jobs.scenes(job.job_id) ``` ```typescript const transcript = await fq.jobs.transcript(job.jobId); for (const seg of transcript.segments) console.log(`[${seg.startTime}-${seg.endTime}] ${seg.speaker}: ${seg.text}`); const srt = await fq.jobs.transcript(job.jobId, { format: "srt" }); // string const scenes = await fq.jobs.scenes(job.jobId); ``` Field-by-field shapes are in [Results](https://www.framequery.com/docs/results). ## Next steps - [Webhooks](https://www.framequery.com/docs/webhooks): get notified instead of polling, and verify signatures. - [Pricing and billing](https://www.framequery.com/docs/pricing): what a job costs, and how to estimate it before you send it. - [Errors](https://www.framequery.com/docs/errors): every error code, and which ones to retry. - [SDKs](https://www.framequery.com/docs/sdks): Python and TypeScript reference. --- # Authentication and keys > API keys are Polar license keys that start with fq_live_. How to get one, send it, keep it server-side and rotate it. Source: https://www.framequery.com/docs/authentication (updated 2026-10-13) ## Sending your key Every API endpoint except the keyless discovery endpoints takes your key in one of two headers: ```bash curl https://api.framequery.com/v1/api/jobs \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" # Same key, alternative header curl https://api.framequery.com/v1/api/jobs \ -H "X-API-Key: $FRAMEQUERY_API_KEY" ``` A missing, invalid, revoked or expired key returns `401` with code [`UNAUTHORIZED`](https://www.framequery.com/docs/errors#UNAUTHORIZED). The SDKs read the key from the `FRAMEQUERY_API_KEY` environment variable when you do not pass one. These endpoints need no key at all, so you can explore prices and formats before you sign up: [`GET /openapi.json`](https://api.framequery.com/v1/api/openapi.json), [`GET /pricing`](https://api.framequery.com/v1/api/pricing), [`GET /pricing/estimate`](https://api.framequery.com/v1/api/pricing/estimate?durationSeconds=3600&mode=all) and [`GET /formats`](https://api.framequery.com/v1/api/formats). ## Getting a key Keys are [Polar](https://polar.sh) license keys. Polar is our billing provider, and it issues a key with the prefix `fq_live_` when you buy API credits: 1. During the public beta, [request access by email](mailto:support@framequery.com?subject=FrameQuery%20API%20beta%20access); we switch accounts on one by one. Once yours is on, sign in and open the [API keys page](https://www.framequery.com/profile/api-keys). 2. Buy a credit pack ($10, $50 or $100). Checkout runs on Polar. 3. Open the Polar customer portal with the **Open Polar portal** button on the API keys page to see the full key. The API keys page itself lists your keys with the secret masked. There is no API endpoint that creates keys, and no way to create one with another key. Keys carry no scopes during the beta: every valid key has full access to its account's jobs, results and balance, and all keys of an account share its [rate limits](https://www.framequery.com/docs/rate-limits) and credits. Verify a key and see your balance in one call: ```bash curl https://api.framequery.com/v1/api/quota \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ## Keep keys server-side A key can spend your credits, so treat it like a password: - Call the API from your backend, a job runner or a script. Never put a key in browser JavaScript, a mobile app or a public repository. - Read it from an environment variable or a secrets manager, not from source code. - The TypeScript SDK refuses to construct a client with a key inside a browser unless you pass `dangerouslyAllowBrowser: true`. Do not. ## Rotating a key Rotate a key whenever it may have leaked, and on a regular schedule: 1. On the [API keys page](https://www.framequery.com/profile/api-keys), click **Rotate** next to the key. 2. The new key is shown once. Copy it into your secret store straight away. 3. The old key stops working. Requests that still use it start returning `401 UNAUTHORIZED`, everywhere within about a minute (validated keys are cached for up to 60 seconds). Because rotation is immediate, deploy the new key first if you can: rotate, update your secret store, redeploy, and expect a brief window of `401`s from anything still holding the old key. ## Dashboard-only endpoints Key and webhook-secret management is done from the FrameQuery dashboard and is authenticated with your signed-in session, not with an API key. These endpoints appear in the OpenAPI document for completeness (the **Keys** tag, and the two webhook-secret endpoints under **Webhooks**); an `fq_live_` key cannot call them. The webhook delivery log and test sender, also under **Webhooks**, do take an API key. | Endpoint | What the dashboard uses it for | |---|---| | `GET /keys` | List your keys: `id`, masked `displayKey`, `status` (`granted`, `revoked`, `disabled`), `expiresAt`, `createdAt` | | `POST /keys/{id}/rotate` | Rotate one key; the response holds the new full `key`, once | | `GET /keys/portal` | A short-lived Polar customer-portal URL (keys, receipts, credit purchases) | | `GET /webhooks/secret` | Reveal your `whsec_` webhook signing secret (created on first use) | | `POST /webhooks/secret/rotate` | Rotate the signing secret (the old one keeps signing for 24 hours) | ## Failure modes | Situation | What you get | |---|---| | No key, malformed key, revoked or expired key | `401 UNAUTHORIZED` | | Many invalid keys from one IP address | That address gets `401` for 10 minutes without the key being checked | | Account suspended | `403 ACCOUNT_SUSPENDED` | | Our billing provider is unreachable | Reads keep working for a while; job creation returns `503 SERVICE_UNAVAILABLE` with `Retry-After` | | Valid key, no credits | Job creation returns `402 INSUFFICIENT_CREDITS` | --- # Jobs > Create jobs from a URL, a direct upload or a batch of clips. Processing modes, idempotency, metadata, multi-track audio, listing and cancelling. Source: https://www.framequery.com/docs/jobs (updated 2026-10-13) A job processes one piece of media (or, in a continuous batch, several clips joined into one). Every create endpoint returns the full [Job object](https://www.framequery.com/docs/job-lifecycle#the-job-object) immediately; processing runs asynchronously. | Endpoint | Use it when | |---|---| | `POST /jobs/from-url` | The media is already reachable over http(s): object storage, a CDN, a presigned link | | `POST /jobs` | The file is on your machine or server; you upload it to a signed URL | | `POST /jobs/batch` | You have up to 100 URLs, either independent clips or one continuous shoot | ## Processing modes `processingMode` chooses which billable stages run. It defaults to `all`. | `processingMode` | Stages | Standard price per footage-hour | |---|---|---| | `all` | vision + transcript | $1.50 | | `vision` | scene detection and captions only | $1.15 | | `transcript` | speech-to-text only | $0.35 per audio track | The input can switch a stage off: an audio-only file has no vision stage, and a video without an audio stream has no transcript stage. Such a stage is reported as `skipped` and never charged, so `all` on an MP3 costs the transcript rate. The ingest stage (probing and preparing the media) always runs and is free. ## Create a job from a URL ```bash curl -X POST https://api.framequery.com/v1/api/jobs/from-url \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7d0c3a52-0f4e-4a54-b1f9-5c1c4c1b8a01" \ -d '{ "url": "https://example.com/footage/A003_C008.mp4", "processingMode": "all", "callbackUrl": "https://your-app.example.com/webhooks/framequery", "metadata": { "assetId": "A003_C008", "project": "spring-promo" } }' ``` The response is the Job object with `state: "queued"`; it moves to `processing` once the fetch starts. | Field | Type | Notes | |---|---|---| | `url` | string, required | Public `http` or `https` URL. Private, link-local and cloud-metadata addresses are refused with [`URL_NOT_ALLOWED`](https://www.framequery.com/docs/errors#URL_NOT_ALLOWED) | | `fileName` | string | Display name and format hint. Defaults to the URL's last path segment | | `downloadToken` | string | Sent as a bearer token when fetching `url`. Used for the download only and never stored | | `provider` | `gdrive` \| `dropbox` | Set when `url` is a Google Drive or Dropbox link fetched with `downloadToken` | | `processingMode` | `all` \| `transcript` \| `vision` | Default `all` | | `callbackUrl` | string | Public HTTPS URL (port 443, at most 2,048 characters) for this job's [webhooks](https://www.framequery.com/docs/webhooks) | | `idempotencyKey` | string, ≤ 255 chars | Or the `Idempotency-Key` header. See [Idempotency](#idempotency) | | `metadata` | object | Your own key/value strings, echoed back. See [Metadata](#metadata) | | `audioUrl`, `audioDownloadToken`, `audioSyncMode`, `audioOffsetMs` | | One separate audio file. See [Multi-track audio](#audio-tracks) | | `audioTracks` | array, ≤ 16 | Several separate audio files. See [Multi-track audio](#audio-tracks) | Presigned URLs from S3, Cloudflare R2, Backblaze B2 and Google Cloud Storage all work. Your storage provider may charge egress for the download; uploading directly (below) costs nothing in transfer fees. ## Upload a local file `POST /jobs` creates the job in state `awaiting_upload` and returns signed upload targets. You upload straight to Google Cloud Storage; the file never passes through our API servers. Processing starts automatically when the upload completes. ```bash curl -X POST https://api.framequery.com/v1/api/jobs \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f1c9a8e-2b47-4d1e-9a51-6c0b2e7d4f10" \ -d '{ "fileName": "A003_C008.mov", "fileSizeBytes": 7340032000, "processingMode": "all" }' ``` ```json { "data": { "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "state": "awaiting_upload", "processingMode": "all", "format": { "tier": "standard", "extension": "mov" }, "stages": { "ingest": { "status": "pending" }, "vision": { "status": "pending" }, "transcript": { "status": "pending" } }, "metadata": {}, "uploadUrl": "https://storage.googleapis.com/…&X-Goog-Signature=…", "resumableUploadUrl": "https://storage.googleapis.com/…&X-Goog-Signature=…", "uploadMethod": "PUT", "uploadHeaders": { "Content-Type": "application/octet-stream", "x-goog-meta-user-id": "…", "x-goog-meta-job-id": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "x-goog-meta-source": "api", "x-goog-if-generation-match": "0", "x-goog-content-length-range": "0,21474836480" }, "expiresInSeconds": 3600, "uploadExpiresAt": "2026-10-13T10:04:03Z", "maxUploadBytes": 21474836480 }, "requestId": "c1a9e0f4d2b84e57" } ``` | Request field | Notes | |---|---| | `fileName` (required) | Its extension decides the format tier (standard or RAW) and blocks non-media types | | `fileSizeBytes` | Optional but recommended: a file over the tier limit fails at once with `413` [`FILE_TOO_LARGE`](https://www.framequery.com/docs/errors#FILE_TOO_LARGE) instead of after the upload | | `processingMode`, `callbackUrl`, `idempotencyKey`, `metadata` | As for URL jobs | | `audioFileName` or `audioTracks[].fileName` | Separate audio to upload. See [Multi-track audio](#audio-tracks) | Rules for the upload: - Send **every** header in `uploadHeaders` verbatim. They are covered by the signature, and the set varies with the request (for example an extra `x-goog-meta-*` header when you set `callbackUrl` or a `processingMode` other than `all`), so copy them from the response rather than hard-coding them. A missing or altered header fails with `403` from Google Cloud Storage. - Do not send your API key, or any `Authorization` header, to the upload URL. - Both URLs expire after `expiresInSeconds` (one hour; `uploadExpiresAt` is the absolute time). Upload the file once; a job that is never uploaded moves to `expired` about an hour after it was created. An idempotent replay of the create returns the seconds left, and once the URLs have expired it returns `expiresInSeconds: 0` without the upload fields: read the job, or create a new job under a new idempotency key. - Each upload URL is **single-use**. `uploadHeaders` (and `audioUploadHeaders`) include `x-goog-if-generation-match: 0`, so storage keeps the first completed object and answers `412 Precondition Failed` to any later `PUT` or resumable finalisation on the same URL. If you do not know whether an upload landed, read the job instead of re-uploading: a `state` past `awaiting_upload` means it did; otherwise create a new job. - `resumableUploadUrl` can be absent. Then use the single `PUT`. - `maxUploadBytes` is the size limit for this file's tier: 20 GiB for standard formats, 100 GiB for camera RAW. See [Formats and limits](https://www.framequery.com/docs/formats). ### Single PUT For files up to a few hundred megabytes, one `PUT` is simplest: ```bash # RESPONSE holds the JSON from POST /jobs HEADERS=() while IFS= read -r h; do HEADERS+=(-H "$h"); done \ < <(echo "$RESPONSE" | jq -r '.data.uploadHeaders | to_entries[] | "\(.key): \(.value)"') curl -X PUT "$(echo "$RESPONSE" | jq -r .data.uploadUrl)" "${HEADERS[@]}" --upload-file A003_C008.mov ``` ### Resumable upload For large files, or unreliable networks, use `resumableUploadUrl` (a [Google Cloud Storage resumable session](https://cloud.google.com/storage/docs/performing-resumable-uploads)). An interrupted upload continues from the last byte stored instead of starting over. 1. Start the session: `POST` to `resumableUploadUrl` with every `uploadHeaders` entry plus `x-goog-resumable: start` and an empty body. The response is `201` with a `Location` header: the session URI. 2. `PUT` the file to the session URI in chunks that are multiples of 256 KiB, each with `Content-Range: bytes -/`. GCS answers `308` with a `Range` header until the last chunk, then `200` or `201`. 3. After a network error, send `PUT` with an empty body and `Content-Range: bytes */` to the session URI. A `308` tells you the next byte to send. ```bash RESUMABLE_URL=$(echo "$RESPONSE" | jq -r .data.resumableUploadUrl) SESSION=$(curl -sS -i -X POST "$RESUMABLE_URL" "${HEADERS[@]}" -H "x-goog-resumable: start" -d '' \ | tr -d '\r' | awk 'tolower($1)=="location:" {print $2}') curl -X PUT "$SESSION" --upload-file A003_C008.mov # curl sends it in one go; retry with Content-Range on failure ``` The Python and TypeScript SDKs do all of this for you (`fq.jobs.create(path)`): single PUT up to 32 MiB, resumable 16 MiB chunks above, streamed from disk, with automatic resume. ## Batch jobs `POST /jobs/batch` takes up to 100 URLs in one request. ```bash curl -X POST https://api.framequery.com/v1/api/jobs/batch \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 5a6f0d21-8c3b-4e7a-9f12-0d4e6b8c2a33" \ -d '{ "mode": "independent", "processingMode": "transcript", "metadata": { "project": "spring-promo" }, "clips": [ { "sourceUrl": "https://example.com/day1/clip-001.mp4", "metadata": { "assetId": "clip-001" } }, { "sourceUrl": "https://example.com/day1/clip-002.mp4", "metadata": { "assetId": "clip-002" } } ] }' ``` | `mode` | Behaviour | |---|---| | `independent` | One job per clip. Each clip counts against your credits and your active-job limit. `clips[].metadata` is merged over the batch `metadata` (clip keys win, at most 16 keys after merging) | | `continuous` | The clips are one continuous shoot and are concatenated into a single job, billed on the total duration. Per-clip `metadata` is rejected | Each clip takes `sourceUrl` (required), `fileName`, `downloadToken` and `provider`, as for URL jobs. The response is `{batchId, mode, jobs[]}`, where each `jobs[]` item is a full Job object plus the clip's `index`. A clip whose fetch could not be queued comes back with `state: "failed"` and error `FETCH_FAILED` while the rest of the batch still runs (if its job could not be created at all, the item has no `jobId` and its error code is `INTERNAL_ERROR`). Inspect each item's `state`. ## Multi-track audio Attach separately recorded audio (a boom track, lavaliers, a podcast's per-guest files) to a video: - One extra file: `audioFileName` (upload) or `audioUrl` (URL jobs), with `audioSyncMode` and `audioOffsetMs`. - Several files: `audioTracks`, up to 16 entries. Each takes `fileName` (upload) or `url` plus optional `downloadToken`, a `label`, `syncMode`, `offsetMs`, and for multi-channel files `perChannelTranscription` and `channels` (channel indices). `syncMode` is `auto` (default: aligned to the video automatically), `timecode` (aligned by embedded timecode) or `offset` (shifted by `offsetMs`, between -86 400 000 and 86 400 000 ms). `audioFileName` and `audioTracks` are mutually exclusive. ```json { "url": "https://example.com/interview/camera-a.mp4", "processingMode": "all", "audioTracks": [ { "url": "https://example.com/interview/host-lav.wav", "label": "Host", "syncMode": "auto" }, { "url": "https://example.com/interview/guest-lav.wav", "label": "Guest", "syncMode": "offset", "offsetMs": -1200 } ] } ``` For upload jobs the create response adds `audioTrackUploadUrls` (`[{index, uploadUrl}]`) or `audioUploadUrl` for a single file, plus `audioUploadHeaders` to send with each `PUT`. The transcript rate is charged per transcribed track. Per-track results come back in the transcript's `tracks[]`, or one at a time with `?track=N`; see [Results](https://www.framequery.com/docs/results#multi-track-transcripts). ## Idempotency Network failures happen after the server has acted. Send an idempotency key with every create so a retry returns the original job instead of creating (and billing) a second one. - Pass it as the `Idempotency-Key` header or the `idempotencyKey` body field (≤ 255 characters; if you send both they must match). A random UUID per logical request is ideal. - Keys are scoped to your account and kept for 24 hours. - Same key and same body: the stored response is replayed, with the header `Idempotent-Replayed: true`. - Same key, different body: `422` [`IDEMPOTENCY_BODY_MISMATCH`](https://www.framequery.com/docs/errors#IDEMPOTENCY_BODY_MISMATCH). - Same key while the first request is still running: `409` [`IDEMPOTENCY_IN_PROGRESS`](https://www.framequery.com/docs/errors#IDEMPOTENCY_IN_PROGRESS) with `Retry-After: 2`. Retry with the same key. The SDKs generate a key for every create call and reuse it on their own retries. Pass your own when a retry can cross process boundaries, for example a queue worker that re-runs a task. ## Metadata Attach your own identifiers to a job with `metadata` on any create request: ```json "metadata": { "assetId": "A003_C008", "project": "spring-promo", "tenant": "acme" } ``` - Up to 16 keys; keys 1 to 40 characters; values are strings of up to 512 characters. - Echoed unchanged as `metadata` on the Job object, in `GET /jobs` list items and in webhook payloads (an empty object when you set none). - Never used for processing and not searchable. Do not put secrets in it. - A violation returns `400` [`INVALID_REQUEST`](https://www.framequery.com/docs/errors#INVALID_REQUEST) with `param` naming the field, for example `metadata.assetId`. ## Get a job ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID?include=none" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` By default the job embeds its results under `processedData` once each stage completes, which can be megabytes for a long video. Pass `include=none` when polling and fetch results from the [results endpoints](https://www.framequery.com/docs/results), or `include=scenes` / `include=transcript` for one of them. A job id that does not exist, or belongs to another account, returns `404 NOT_FOUND`. ## List jobs ```bash curl "https://api.framequery.com/v1/api/jobs?state=completed&limit=50" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ```json { "data": [ { "jobId": "0b5e6c1e-…", "state": "completed", "metadata": { "assetId": "A003_C008" } } ], "hasMore": true, "nextCursor": "6c1f0d8a-2e4b-4f1a-8c3d-9b7e5a2f1c04", "requestId": "e4d2a7b1c9f04a36" } ``` - Newest first. `limit` is 1 to 100 (default 20). Filter by `state`; the deprecated `status` filter still works but cannot be combined with `state`. - Fetch the next page with `?cursor=` (and the same `state` and `limit`). The full next-page URL is also in the `Link: <…>; rel="next"` header. - A page can hold fewer than `limit` items, even zero. Keep going while `hasMore` is `true`. - List items carry `stages` with `status` only, `metadata` and `links`; get the job for the full object. ## Cancel a job ```bash curl -X POST "https://api.framequery.com/v1/api/jobs/$JOB_ID/cancel" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Cancelling is allowed while the job is `awaiting_upload`, `queued` or `processing`, takes no request body, returns the Job object in state `cancelled`, and sends a `job.cancelled` webhook. On a job that already finished it returns `409` [`JOB_NOT_CANCELLABLE`](https://www.framequery.com/docs/errors#JOB_NOT_CANCELLABLE). What you pay: nothing for a job cancelled before processing starts. Stages that completed before the cancel stay charged and their results stay readable. Stages that had not started are skipped and never charged. A stage that was already running can still finish; if it does, it is charged like any completed stage and its results are readable. `DELETE /jobs/{jobId}` is a deprecated alias with the same behaviour; its responses carry `Deprecation: true`. Use `POST /jobs/{jobId}/cancel`. --- # Job lifecycle and state > How a job moves from creation to a terminal state, what each stage reports, how to poll, and every field of the Job object. Source: https://www.framequery.com/docs/job-lifecycle (updated 2026-10-13) ## States Every Job object has a `state`. It is derived from the job's stages by one rule on the server, and it is the only field you should branch on. | `state` | Meaning | Terminal | |---|---|---| | `awaiting_upload` | Created with `POST /jobs`; waiting for **you** to upload the file | no | | `queued` | A URL or batch job waiting for its fetch to start | no | | `processing` | At least one stage is running or still to run | no | | `completed` | Every stage the job needs has completed (or was skipped) | yes | | `failed` | A stage the job needs failed. `error` says which and why | yes | | `cancelled` | You cancelled it | yes | | `expired` | It was never uploaded, or did not finish in time | yes | ```text POST /jobs ──────────► awaiting_upload ──(upload lands)──┐ POST /jobs/from-url ─► queued ───────────(fetch starts)──┴─► processing ─┬─► completed ├─► failed any non-terminal state ──(POST /jobs/{jobId}/cancel)──► cancelled └─► expired ``` Ignore the `status` field. It is the legacy internal pipeline status, kept for older clients; its values are not part of the contract and change without notice. The `GET /jobs?state=` filter takes the same values as `state`. A job left in `awaiting_upload` expires about an hour after creation, when its upload URLs stop working. Jobs that stall in the pipeline also end as `expired` (or `failed`) rather than hanging forever. ## Stages `stages` always has three entries, `ingest`, `vision` and `transcript`, each with a `status`: | Stage `status` | Meaning | |---|---| | `pending` | Not started | | `running` | In progress | | `completed` | Done; its results are readable | | `failed` | Did not complete; `error` holds `{code, message}`. Never charged | | `skipped` | Will not run: excluded by `processingMode`, no video stream (vision) or no audio stream (transcript). Never charged | Stages also carry `startedAt` and `completedAt` (RFC 3339) once known. `ingest` probes and prepares the media; it is free. `vision` and `transcript` are the billable stages and run in parallel after ingest. The derived rule: - The job needs `ingest`, plus `vision` unless the mode is `transcript` or the input has no video, plus `transcript` unless the mode is `vision` or the input has no audio. - Cancelled → `cancelled`. Expired → `expired`. - Any needed stage `failed` → `failed`, with `error.stage` naming it. - Every needed stage `completed` or `skipped` → `completed`. - Otherwise `awaiting_upload`, `queued` or `processing`. ### Partial results Each stage's output is readable as soon as that stage completes, even while the other is still running and even if the job ends `failed`. If vision fails but the transcript completes, the job is `failed`, `GET /jobs/{jobId}/transcript` still works, and you pay for the transcript only. Use the stage statuses to decide what to fetch: ```python job = fq.jobs.get(job_id) if job.stages.transcript.status == "completed": transcript = fq.jobs.transcript(job_id) if job.stages.vision.status == "completed": scenes = fq.jobs.scenes(job_id) ``` ## Polling ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID?include=none" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` - Use `include=none`: without it the response embeds every scene and transcript segment. - Poll every 5 to 15 seconds, backing off for long jobs. When `estimatedCompletionTimeSeconds` is present, polling at about a third of it is plenty. - GET requests are limited to 600 per minute per account (shared by all your keys). On `429`, wait for `Retry-After`. - Better still, set `callbackUrl` at creation and let a [webhook](https://www.framequery.com/docs/webhooks) tell you when the job is done. Keep a slow poll as a fallback. ## The Job object `GET /jobs/{jobId}` (and every create endpoint) returns this shape. Optional fields are omitted when unknown. ```json { "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "state": "completed", "status": "VISION_COMPLETED", "processingMode": "all", "format": { "tier": "standard", "container": "mov", "extension": "mov" }, "stages": { "ingest": { "status": "completed", "startedAt": "2026-10-13T09:00:04Z", "completedAt": "2026-10-13T09:01:12Z" }, "vision": { "status": "completed", "startedAt": "2026-10-13T09:01:13Z", "completedAt": "2026-10-13T09:03:40Z" }, "transcript": { "status": "completed", "startedAt": "2026-10-13T09:01:13Z", "completedAt": "2026-10-13T09:04:02Z" } }, "metadata": { "assetId": "A003_C008" }, "links": { "self": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "transcript": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/transcript", "scenes": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/scenes" }, "originalFilename": "A003_C008.mov", "source": "api", "durationSeconds": 1847.5, "billing": { "tier": "standard", "currency": "USD", "billableSeconds": 1848, "estimatedCents": 77, "chargedCents": 77 }, "createdAt": "2026-10-13T08:59:41Z", "updatedAt": "2026-10-13T09:04:03Z", "completedAt": "2026-10-13T09:04:03Z", "statusHistory": [{ "status": "PENDING_UPLOAD", "timestamp": "2026-10-13T08:59:41Z" }] } ``` | Field | Notes | |---|---| | `jobId` | UUID | | `state` | See [States](#states) | | `status` | Legacy, informational only | | `processingMode` | `all`, `transcript` or `vision` | | `format` | `tier` (`standard` or `raw`), and `container` / `extension` when detected | | `stages` | `ingest`, `vision`, `transcript`, each `{status, startedAt?, completedAt?, error?}` | | `error` | Present when `state` is `failed`: `{code, message, stage}`. Codes are listed under [job error codes](https://www.framequery.com/docs/errors#job-error-codes) | | `metadata` | Your create-time metadata; always an object | | `links` | Absolute URLs: `self`, and `transcript`, `scenes`, `cancel` when they apply (`cancel` disappears once the job is terminal; a skipped stage has no link) | | `originalFilename`, `source` | `source` is `api` (upload), `url` or `batch` | | `durationSeconds` | Media duration, once probed | | `billing` | `tier`, `currency` (`USD`), `billableSeconds` (per second, 30 s minimum), `estimatedCents` (the current hold for this job), `chargedCents` (charged so far, completed stages only) | | `createdAt`, `updatedAt`, `completedAt` | RFC 3339 | | `estimatedCompletionTimeSeconds` | A hint while the job runs | | `audioTrackCount`, `audioTracksCompleted`, `audioTrackNames`, `hasMultiTrackAudio` | Multi-track audio jobs | | `batchId`, `clipCount`, `continuousShoot` | Batch jobs | | `processedData` | Embedded results (`scenes`, `transcript`, `speakers`, `language`, `length`) unless `include=none`. Prefer the [results endpoints](https://www.framequery.com/docs/results) | | `errorMessage` | Legacy copy of `error.message` | `GET /jobs` list items carry the common fields (`jobId`, `state`, `status`, `processingMode`, `format`, `stages` with `status` only, `error`, `metadata`, `links`, `originalFilename`, `source`, `durationSeconds`, `batchId` and the timestamps). Get the job for `billing`, `processedData`, `statusHistory` and the audio-track fields. --- # Results > Read transcripts as JSON, SRT, WebVTT or plain text, and scenes with captions, objects, palettes and shot types. What "not ready" and "unavailable" mean. Source: https://www.framequery.com/docs/results (updated 2026-10-13) Each billable stage has its own endpoint. Results are readable as soon as that stage's status is `completed`, whatever the other stage is doing. | Endpoint | Returns | |---|---| | `GET /jobs/{jobId}/transcript` | The transcript as JSON, or `?format=srt`, `vtt` or `txt` as a file | | `GET /jobs/{jobId}/scenes` | Detected scenes with captions | | `GET /jobs/{jobId}/audioTracks` | Per-track transcripts of a multi-track job (stored form) | ## Transcript ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/transcript" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ```json { "data": { "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "language": "en", "speakers": ["Speaker 1", "Speaker 2"], "text": "Morning. Did you see the forecast for tomorrow? Rain all day, apparently.", "segments": [ { "startTime": 40.2, "endTime": 41.0, "text": "Morning.", "speaker": "Speaker 1" }, { "startTime": 41.6, "endTime": 44.1, "text": "Did you see the forecast for tomorrow?", "speaker": "Speaker 2" }, { "startTime": 44.5, "endTime": 46.3, "text": "Rain all day, apparently.", "speaker": "Speaker 1" } ] }, "requestId": "5d7e1f0a3b9c4e28" } ``` | Field | Notes | |---|---| | `language` | Detected language code, for example `en` | | `speakers` | Every speaker label used by a segment, `"Speaker 1"`, `"Speaker 2"` and so on | | `text` | The whole transcript: segment texts joined by single spaces | | `segments[].startTime`, `endTime` | Seconds (float) from the start of the media | | `segments[].text` | The words spoken in the segment | | `segments[].speaker` | One entry of `speakers`, or `null` when diarisation could not attribute the segment | | `segments[].speakerTag` | Deprecated numeric form of `speaker` (`"Speaker 2"` is `2`). Use `speaker` | | `tracks`, `trackIndex`, `trackName` | Multi-track jobs; see below | Speaker labels are consistent within a job, not across jobs: `Speaker 1` in one job is not the same person as `Speaker 1` in another. Word-level timestamps are not part of the beta API. ### SRT, WebVTT and plain text The server renders caption files, so every client gets identical output. They are sent as downloads (`Content-Disposition: attachment`). ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/transcript?format=srt" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" -o interview.srt ``` ```text 1 00:00:40,200 --> 00:00:41,000 Speaker 1: Morning. 2 00:00:41,600 --> 00:00:44,100 Speaker 2: Did you see the forecast for tomorrow? ``` ```text WEBVTT 00:00:40.200 --> 00:00:41.000 Morning. 00:00:41.600 --> 00:00:44.100 Did you see the forecast for tomorrow? ``` ```text Speaker 1: Morning. Speaker 2: Did you see the forecast for tomorrow? ``` Segments without a speaker have no label. On a multi-track job the file formats need `track`; without it the request fails with `400 INVALID_REQUEST` and `param: "track"`. ### Multi-track transcripts For a job with several audio tracks (see [Multi-track audio](https://www.framequery.com/docs/jobs#audio-tracks)): - Without `track`, the JSON response adds `tracks[]`. Each entry has `trackIndex`, `trackName` (your `label`), `status`, `language`, `speakers`, `text`, `segments` and, when that track failed, `errorCode`. - `?track=1` returns that one track in the top-level shape, with `trackIndex` and `trackName` set. A track that failed returns `404 RESULTS_UNAVAILABLE` with `reason: "track_failed"` and its `trackIndex`. ## Scenes ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/scenes" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ```json { "data": { "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "scenes": [ { "index": 0, "startTime": 0.0, "endTime": 6.4, "description": "Aerial view of a coastal town at sunrise with fishing boats moored in the harbour.", "objects": ["town", "harbour", "fishing-boat", "sea", "hills"], "colorPalette": ["#f2b56b", "#2f5d7c", "#c9d6df", "#8a4f3d"], "shotType": "extreme-wide", "shotAngle": "aerial" }, { "index": 1, "startTime": 6.4, "endTime": 11.9, "description": "A fisherman in a yellow jacket coils rope on the deck of a small boat.", "objects": ["fisherman", "yellow-jacket", "rope", "boat-deck"], "colorPalette": ["#e3c13b", "#3e4a52", "#a7b3ba", "#6b4f3a"], "shotType": "medium", "shotAngle": "high-angle" } ] }, "requestId": "a2c4e6f8b0d14f39" } ``` | Field | Notes | |---|---| | `index` | Position in the list; scenes are ordered by `startTime` | | `startTime`, `endTime` | Seconds (float) | | `description` | One sentence describing the subject, action, setting and key visual details | | `objects` | Up to about 10 visible objects as short lowercase labels (`"red-car"`). Labels, not bounding boxes | | `colorPalette` | Dominant colours of the frame as hex strings, most dominant first | | `shotType` | One of `extreme-wide`, `wide`, `full`, `medium-wide`, `medium`, `medium-close-up`, `close-up`, `extreme-close-up`, `over-the-shoulder`, `two-shot`, `group-shot`, `pov`, `insert`, `cutaway`, `reaction`, `aerial`, `silhouette`, `static-graphic` | | `shotAngle` | One of `eye-level`, `low-angle`, `high-angle`, `dutch`, `aerial` | | `startTs`, `endTs` | Deprecated aliases of `startTime` / `endTime` for SDK 0.1. They will be removed | A video without scene cuts returns an empty `scenes` array. Treat unknown `shotType` or `shotAngle` values as plain strings: the vocabulary can grow. ## Not ready versus unavailable The results endpoints tell you whether to wait or to stop: | Response | Meaning | What to do | |---|---|---| | `200` | The stage completed | Use the data | | `409` [`RESULTS_NOT_READY`](https://www.framequery.com/docs/errors#RESULTS_NOT_READY) | The stage is `pending` or `running` and may still complete. The body adds `state`, `stage`, `stageStatus` | Retry after `Retry-After` (15 s), or wait for the webhook | | `404` [`RESULTS_UNAVAILABLE`](https://www.framequery.com/docs/errors#RESULTS_UNAVAILABLE) | The output does not exist and never will. The body adds `state`, `stage`, `stageStatus`, `reason`, and `errorCode` or `trackIndex` when relevant | Stop polling. Read `reason` | | `404` [`NOT_FOUND`](https://www.framequery.com/docs/errors#NOT_FOUND) | No such job (or track) for this key | Check the id | ```json { "error": "This job has no vision results and none will be produced (reason: audio_only_input).", "code": "RESULTS_UNAVAILABLE", "docUrl": "https://www.framequery.com/docs/errors#RESULTS_UNAVAILABLE", "state": "completed", "stage": "vision", "stageStatus": "skipped", "reason": "audio_only_input", "requestId": "f0e1d2c3b4a54968" } ``` | `reason` | Why | |---|---| | `processing_mode` | The job's `processingMode` excludes this stage | | `audio_only_input` | The input has no video, so there is no vision stage | | `no_audio` | The input has no audio stream, so there is no transcript | | `stage_skipped` | The stage was skipped (cause not recorded) | | `stage_failed` | The stage failed; `errorCode` says why | | `track_failed` | The requested audio track failed | | `job_cancelled` | The job was cancelled before this stage ran | | `job_expired` | The job expired before this stage ran | | `job_failed` | Another stage failed before this one started | ## Embedded results and audio-track endpoints `GET /jobs/{jobId}` embeds results under `processedData` (`scenes`, `transcript`, `speakers`, `language`, `length`) unless you pass `include=none`. The embedded transcript uses the stored entry form, which older clients read. New code should use the endpoints above. `GET /jobs/{jobId}/audioTracks` and `GET /jobs/{jobId}/audioTracks/{trackIndex}` return each track's stored transcript (`trackIndex`, `trackName`, `status`, `transcript`, `speakers`, `language`, `errorCode`). The transcript endpoint's `tracks[]` and `?track=` cover the same data in the documented segment shape. --- # Webhooks > Get a signed HTTPS callback when a job completes, fails or is cancelled. Payload, Standard Webhooks signature verification, retries, secret rotation, testing and the delivery log. Source: https://www.framequery.com/docs/webhooks (updated 2026-10-13) ## Receiving webhooks Set `callbackUrl` when you create a job (`POST /jobs`, `POST /jobs/from-url`, or once for a whole `POST /jobs/batch`). It must be a public HTTPS URL on port 443, without credentials and at most 2,048 characters; anything else is rejected with `400` [`INVALID_CALLBACK_URL`](https://www.framequery.com/docs/errors#INVALID_CALLBACK_URL). ```bash curl -X POST https://api.framequery.com/v1/api/jobs/from-url \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/interview.mp4", "callbackUrl": "https://your-app.example.com/webhooks/framequery" }' ``` When the job becomes `completed`, `failed` or `cancelled` we `POST` one event to that URL. A job that ends `expired` (never uploaded, or stalled) sends no event, so keep a slow poll as a fallback: | Event | Sent when | |---|---| | `job.completed` | `state` became `completed` | | `job.failed` | `state` became `failed` (`data.error` says why; completed stages are still readable) | | `job.cancelled` | The job was cancelled | | `webhook.test` | You called `POST /webhooks/test` | Webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so any Standard Webhooks library can verify them. ## Payload ```http POST /webhooks/framequery HTTP/1.1 Content-Type: application/json User-Agent: FrameQuery-Webhooks/1.0 webhook-id: 6f1c2a9e-3b7d-5e0f-8a41-2c9d7b6e1f03 webhook-timestamp: 1760346243 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= ``` ```json { "type": "job.completed", "timestamp": "2026-10-13T09:04:03Z", "data": { "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "state": "completed", "processingMode": "all", "stages": { "ingest": { "status": "completed" }, "vision": { "status": "completed" }, "transcript": { "status": "completed" } }, "metadata": { "assetId": "A003_C008" }, "links": { "self": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "transcript": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/transcript", "scenes": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/scenes" } } } ``` `data` is the full [Job object](https://www.framequery.com/docs/job-lifecycle#the-job-object) (trimmed above) without `processedData`. Fetch results from `data.links.transcript` and `data.links.scenes` with your API key. Your `metadata` is included, so you can route the event without a lookup. | Header | Meaning | |---|---| | `webhook-id` | Event id. **Stable across retries** of the same event: use it to deduplicate | | `webhook-timestamp` | Unix seconds when this attempt was signed (new on every attempt) | | `webhook-signature` | Space-separated list of `v1,` signatures. Two entries during a secret rotation | ## Your signing secret Each account has one signing secret, `whsec_` followed by base64. Reveal it on the [API keys page](https://www.framequery.com/profile/api-keys) under **Webhook signing secret** (it is created the first time you open it) and store it as `FRAMEQUERY_WEBHOOK_SECRET`. It is a different secret from your API key. **Rotation.** Click **Rotate** to issue a new secret. For the next 24 hours every delivery is signed with both the new and the previous secret (two `v1,` entries), so receivers holding either one keep verifying while you deploy the new value. After 24 hours only the new secret signs. ## Verifying signatures The signature is `base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"))`, where `key` is the base64-decoded part of the secret after `whsec_`. Verify against the **raw request body**, before any JSON parsing; re-serialised JSON will not match. Reject timestamps more than five minutes from your clock, and compare in constant time. ```python # pip install framequery (FastAPI shown; any framework works) from fastapi import FastAPI, Request, Response from framequery import WebhookVerificationError, webhooks app = FastAPI() @app.post("/webhooks/framequery") async def framequery_webhook(request: Request): try: # Secret defaults to FRAMEQUERY_WEBHOOK_SECRET event = webhooks.unwrap(await request.body(), request.headers) except WebhookVerificationError: return Response(status_code=400) if event.type == "job.completed": enqueue_fetch(event.data.job_id, dedupe_key=event.id) # your code return Response(status_code=204) ``` ```typescript // npm install framequery (Express shown) import express from "express"; import FrameQuery from "framequery"; const fq = new FrameQuery(); // webhookSecret defaults to FRAMEQUERY_WEBHOOK_SECRET const app = express(); app.post("/webhooks/framequery", express.raw({ type: "application/json" }), async (req, res) => { try { const event = await fq.webhooks.unwrap(req.body, req.headers); if (event.type === "job.completed") await enqueueFetch(event.data.jobId, event.id); // your code res.sendStatus(204); } catch { res.sendStatus(400); } }); ``` ```javascript // No dependencies. Node 18+. import crypto from "node:crypto"; export function verifyFrameQueryWebhook(rawBody, headers, secret, toleranceSeconds = 300) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) throw new Error("missing webhook headers"); if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) throw new Error("stale timestamp"); const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest(); const valid = String(signatures) .split(" ") .some((entry) => { const [version, signature] = entry.split(","); if (version !== "v1" || !signature) return false; const given = Buffer.from(signature, "base64"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); if (!valid) throw new Error("invalid signature"); } // Express: app.post("/webhooks/framequery", express.raw({ type: "application/json" }), (req, res) => { // verifyFrameQueryWebhook(req.body, req.headers, process.env.FRAMEQUERY_WEBHOOK_SECRET); // const event = JSON.parse(req.body); ... res.sendStatus(204); }); ``` ```go // Standard library only. package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "net/http" "strconv" "strings" "time" ) func Verify(secret string, h http.Header, body []byte) error { id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") if id == "" || ts == "" || sigs == "" { return errors.New("missing webhook headers") } t, err := strconv.ParseInt(ts, 10, 64) if err != nil { return errors.New("invalid timestamp") } if d := time.Since(time.Unix(t, 0)); d > 5*time.Minute || d < -5*time.Minute { return errors.New("stale timestamp") } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return errors.New("invalid secret") } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := mac.Sum(nil) for _, entry := range strings.Fields(sigs) { version, sig, ok := strings.Cut(entry, ",") if !ok || version != "v1" { continue } if given, err := base64.StdEncoding.DecodeString(sig); err == nil && hmac.Equal(given, expected) { return nil } } return errors.New("invalid signature") } // In your handler: body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // if err := webhooks.Verify(os.Getenv("FRAMEQUERY_WEBHOOK_SECRET"), r.Header, body); err != nil { w.WriteHeader(400); return } ``` ```python import base64, hashlib, hmac, time def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> None: msg_id = headers["webhook-id"] ts = headers["webhook-timestamp"] signatures = headers["webhook-signature"] if abs(time.time() - int(ts)) > tolerance: raise ValueError("stale timestamp") key = base64.b64decode(secret.removeprefix("whsec_")) expected = base64.b64encode( hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest() ).decode() if not any( hmac.compare_digest(expected, s.split(",", 1)[1]) for s in signatures.split() if s.startswith("v1,") ): raise ValueError("invalid signature") ``` The Standard Webhooks reference libraries (`pip install standardwebhooks`, `npm install standardwebhooks`, `github.com/standard-webhooks/standard-webhooks/libraries/go`, and others for Rust, Ruby, PHP and more) verify the same signatures. ## Responding, retries and failures - Return any `2xx` within 10 seconds to acknowledge. Do the real work (fetching results) asynchronously after you respond. - `429`, `5xx`, a timeout or a network error is retried: up to 8 attempts in total, with exponential backoff from 10 seconds up to 10 minutes between attempts. The exception is `job.cancelled` sent by `POST /jobs/{id}/cancel`, which gets 3 quick attempts (about 1 and 2 seconds apart) and is retried later only if the job emits another event. - Any other `4xx`, a redirect (redirects are never followed) or an address that fails our safety checks is final and is not retried. - `410 Gone` acknowledges the event and stops further deliveries for that job and event type. - Retries reuse the same `webhook-id`, and events can arrive more than once or out of order. Deduplicate on `webhook-id` and re-read the job if order matters. - Deliveries go only to public HTTPS endpoints; private and internal addresses are refused. Webhooks are a notification, not the only source of truth: if your endpoint was down for longer than the retry window, `GET /jobs?state=completed` and the delivery log let you catch up. ## Testing an endpoint Send a signed `webhook.test` event to any URL (HTTPS on port 443, public address; 5 requests per minute): ```bash curl -X POST https://api.framequery.com/v1/api/webhooks/test \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/framequery" }' ``` ```json { "data": { "delivered": true, "statusCode": 204, "latencyMs": 182 }, "requestId": "0c9b8a7d6e5f4321" } ``` On failure `delivered` is `false`, `statusCode` is your endpoint's status (`0` when there was no response) and `error` names the failure class, for example `http_5xx: 503`, `timeout` or `redirect`. ## Delivery log The last 100 deliveries for your account, newest first, optionally for one job: ```bash curl "https://api.framequery.com/v1/api/webhooks/deliveries?jobId=$JOB_ID" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ```json { "data": [ { "id": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10:job.completed", "jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10", "eventType": "job.completed", "urlHost": "your-app.example.com", "status": "delivered", "attempts": 2, "lastStatus": 204, "disabled": false, "deliveredAt": "2026-10-13T09:04:21Z", "createdAt": "2026-10-13T09:04:03Z" } ], "requestId": "7a6b5c4d3e2f1a09" } ``` | `status` | Meaning | |---|---| | `in_flight` | Being delivered now | | `pending_retry` | Last attempt got `429`, `5xx` or timed out; it will be retried | | `delivered` | A `2xx` was received | | `failed` | Another `4xx`, a redirect or a blocked address; not retried | | `disabled` | Your endpoint answered `410`; no further deliveries for this job and event | `lastError` holds the failure class and the first 4 KiB of your endpoint's response body, which is usually enough to see why it rejected the event. --- # Formats and limits > Anything FFmpeg can decode, audio-only files, and camera RAW where enabled. Size and duration limits per tier, and the live GET /formats endpoint. Source: https://www.framequery.com/docs/formats (updated 2026-10-13) ## Check the live list `GET /formats` needs no key and always reflects what the API accepts right now, including which camera RAW formats are switched on: ```bash curl https://api.framequery.com/v1/api/formats ``` ```json { "data": { "standard": ["mp4", "mov", "m4v", "mkv", "webm", "avi", "mxf", "…", "mp3", "wav", "flac", "m4a"], "audioOnly": ["mp3", "wav", "aiff", "aif", "flac", "aac", "m4a", "ogg", "oga", "opus", "wma", "amr", "ac3", "caf"], "raw": { "r3d": true, "braw": false, "arri": false }, "rawExtensions": { "r3d": "r3d", "nev": "r3d", "braw": "braw", "ari": "arri", "arx": "arri" }, "blocked": ["app", "bat", "cmd", "exe", "…"], "unknownExtensions": "accepted", "limits": { "standardMaxBytes": 21474836480, "rawMaxBytes": 107374182400, "maxDurationSec": 43200, "uploadUrlExpirySeconds": 3600 } } } ``` The values above are illustrative; trust the live response. ## Standard formats The standard tier covers anything FFmpeg can decode. The advertised video extensions are `mp4`, `mov`, `m4v`, `mkv`, `webm`, `avi`, `wmv`, `asf`, `flv`, `f4v`, `ts`, `m2ts`, `mts`, `mpg`, `mpeg`, `mpe`, `m2v`, `vob`, `3gp`, `3g2`, `mxf`, `dv`, `gxf`, `lxf`, `ogv`, `ogm`, `rm`, `rmvb`, `y4m`, `ivf`, `h264`, `264`, `h265`, `hevc`, `mjpeg`, `mjpg`, `mod`, `tod`, `dvr-ms` and `nut`, which covers H.264, H.265/HEVC, ProRes, DNxHD/DNxHR, AV1, VP9 and the other common codecs inside them. Unknown extensions are accepted too: the file is probed with FFprobe when processing starts, and a file it cannot decode fails with job error `VALIDATION_FAILED` (never charged). ## Audio-only files `mp3`, `wav`, `aiff`, `aif`, `flac`, `aac`, `m4a`, `ogg`, `oga`, `opus`, `wma`, `amr`, `ac3` and `caf` are processed as audio. There is no picture, so the vision stage is `skipped` and the job is billed at the transcript rate even with `processingMode: "all"`. Requesting scenes returns `404 RESULTS_UNAVAILABLE` with `reason: "audio_only_input"`. ## Camera RAW Camera RAW is decoded on GPUs with the camera makers' SDKs and priced on its own [RAW tier](https://www.framequery.com/docs/pricing). Each format is switched on separately; `raw` in `GET /formats` says which ones are live. | Format | Extensions | Format key | |---|---|---| | RED R3D | `.r3d`, `.nev` | `r3d` | | Blackmagic RAW | `.braw` | `braw` | | ARRIRAW | `.ari`, `.arx` | `arri` | Creating a job for a RAW format that is not enabled fails at once with `415` [`UNSUPPORTED_FORMAT`](https://www.framequery.com/docs/errors#UNSUPPORTED_FORMAT). RAW formats are switched on one at a time, R3D first, then BRAW and ARRIRAW; `raw` in `GET /formats` shows which are live right now. ## Blocked file types Executables and scripts (`exe`, `bat`, `cmd`, `sh`, `py`, `js`, `jar`, `dll`, `msi`, `ps1` and similar) are refused at creation with `415 UNSUPPORTED_FORMAT`. The full list is `blocked` in `GET /formats`. ## Limits | Limit | Standard | Camera RAW | |---|---|---| | File size | 20 GiB (21 474 836 480 bytes) | 100 GiB (107 374 182 400 bytes) | | Duration per file | 12 hours | 12 hours | | Upload URL lifetime | 1 hour | 1 hour | | Separate audio tracks per job | 16 | 16 | | Clips per batch request | 100 | 100 | | Minimum billable duration | 30 seconds | 30 seconds | - Send `fileSizeBytes` on `POST /jobs` and an oversize file fails immediately with `413` [`FILE_TOO_LARGE`](https://www.framequery.com/docs/errors#FILE_TOO_LARGE) (the body includes `maxUploadBytes` and `formatTier`). The upload URL also enforces the limit. - A file longer than the duration limit fails during ingest with `VALIDATION_FAILED`. Need longer files or a higher size limit? [Contact us](https://www.framequery.com/contact); limits can be raised per account. - Request and rate limits are on [Rate limits](https://www.framequery.com/docs/rate-limits). ## Getting media to us cheaply - **Direct upload** (`POST /jobs`) goes straight to Google Cloud Storage and costs you nothing in transfer fees. - **From a URL**: you pay whatever your storage provider charges for egress. A provider without egress fees, such as Cloudflare R2, makes URL jobs free to fetch; for large RAW files in a cloud that does charge egress, the transfer can cost more than the processing. --- # Pricing and billing > Per-stage prices per footage-hour, charged per second and only for stages that complete. Prepaid credits through Polar, the estimate endpoint, worked examples and an honest comparison. Source: https://www.framequery.com/docs/pricing (updated 2026-10-13) ## Prices US dollars per footage-hour (an hour of source media, not of processing time), per stage: | Stage | Standard formats | Standard, volume | Camera RAW | Camera RAW, volume | |---|---:|---:|---:|---:| | Vision (scenes and captions) | $1.15 | $0.80 | $1.65 | $1.30 | | Transcript (per audio track) | $0.35 | $0.20 | $0.85 | $0.70 | | **Both (`processingMode: "all"`, one audio track)** | **$1.50** | **$1.00** | **$2.50** | **$2.00** | - **Camera RAW** means R3D, BRAW and ARRIRAW, decoded on GPUs. Everything else, including ProRes and other high-bitrate intermediates, is standard. - **Volume** rates apply to the footage beyond 500 footage-hours in a UTC calendar month, marginally: the first 500 hours of the month are always at the standard rate. - The transcript rate is charged once per transcribed audio track. `GET /pricing` returns this table (in US cents) and is the source of truth; it needs no key: ```bash curl https://api.framequery.com/v1/api/pricing ``` ```json { "data": { "currency": "USD", "unit": "footage_hour", "standard": { "visionCentsPerHour": 115, "transcriptCentsPerHour": 35, "volumeVisionCentsPerHour": 80, "volumeTranscriptCentsPerHour": 20, "allCentsPerHour": 150, "volumeAllCentsPerHour": 100 }, "raw": { "visionCentsPerHour": 165, "transcriptCentsPerHour": 85, "volumeVisionCentsPerHour": 130, "volumeTranscriptCentsPerHour": 70, "allCentsPerHour": 250, "volumeAllCentsPerHour": 200 }, "volumeThresholdSeconds": 1800000, "volumeWindow": "calendar_month_utc", "minBillableSeconds": 30, "rounding": "per_second_ceil_total_to_cent" } } ``` ## How charging works - **Per completed stage.** Each billable stage is charged when it completes. A stage that fails or is skipped is never charged, so there is nothing to refund. Ingest is free. - **Per second.** Billable seconds are the media duration rounded up to the next second, with a 30-second minimum per job. The job total is rounded up to the whole cent once (minimum 1 cent), and every stage that runs costs at least 1 cent, so a very short `all` job can cost a cent more than the rounded total (32 to 48 seconds costs $0.03). `GET /pricing/estimate` returns the exact figure. - **Cancelling** charges only the stages that complete; see [Cancel a job](https://www.framequery.com/docs/jobs#cancel). - **Holds.** Once the duration is known, the job's full price is held against your balance so running jobs cannot overspend it. `billing.estimatedCents` on the job is the current hold and `billing.chargedCents` is what has been charged so far (completed stages only). The hold is released when the job ends. - **Credit checks.** Creating a job needs available credits for at least 30 seconds of footage, otherwise `402` [`INSUFFICIENT_CREDITS`](https://www.framequery.com/docs/errors#INSUFFICIENT_CREDITS). If the balance cannot cover the whole file once its duration is known, the job fails with job error `INSUFFICIENT_CREDITS` and nothing is charged. ## Estimate a job before you send it `GET /pricing/estimate` runs the same function that bills, so it cannot drift from the real charge. No key needed. ```bash curl "https://api.framequery.com/v1/api/pricing/estimate?durationSeconds=2700&tier=standard&mode=all&audioTracks=1" ``` ```json { "data": { "cents": 113, "usd": 1.13, "currency": "USD", "billableSeconds": 2700, "durationSeconds": 2700, "tier": "standard", "mode": "all", "audioTracks": 1, "priorUsageSeconds": 0 } } ``` | Parameter | Notes | |---|---| | `durationSeconds` (required) | Footage duration, 0 to 2 678 400 | | `tier` | `standard` (default) or `raw` | | `mode` | `all` (default), `vision` or `transcript` | | `audioTracks` | Transcribed tracks, 1 to 16 (default 1) | | `priorUsageSeconds` | Billable seconds already used this UTC month, to include volume pricing. Use `api.monthUsageSeconds` from `GET /quota` | ## Worked examples | Job | Calculation | Price | |---|---|---:| | 2-minute clip, `all` | 120 s × $1.50/h | $0.05 | | 10-second clip, `all` | billed as 30 s × $1.50/h = 1.25¢, rounded up | $0.02 | | 45-minute interview, `all` | 2 700 s × $1.50/h = $1.125 | $1.13 | | 45-minute interview, `transcript` | 2 700 s × $0.35/h = $0.2625 | $0.27 | | 1 hour with 4 audio tracks, `all` | $1.15 + 4 × $0.35 | $2.55 | | 1 hour of 8K RED R3D, `all` | 1 h × $2.50 | $2.50 | | 30 minutes of R3D, `vision` | 1 800 s × $1.65/h = $0.825 | $0.83 | | 1 hour `all`, vision failed, transcript completed | transcript only | $0.35 | | 600 hours in a month, `all` | 500 h × $1.50 + 100 h × $1.00 | about $850 | ## Credits and billing The API is prepaid. One credit is one US cent. - **Buy credits** on the [API keys page](https://www.framequery.com/profile/api-keys) in packs of **$10** (1,000 credits), **$50** (5,000) or **$100** (10,000). Checkout runs on [Polar](https://polar.sh), our billing provider, and the purchase also issues your API key. - **See your balance** with `GET /quota`, or in the Polar customer portal (the **Open Polar portal** button on the API keys page). - **Enterprise pay-as-you-go**: accounts with high or unpredictable volume can be billed monthly in arrears instead of prepaying, with an optional monthly cap. [Contact us](https://www.framequery.com/contact) to set it up. ```bash curl https://api.framequery.com/v1/api/quota \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` `data.api` holds `balanceCents` (credits bought minus credits used), `heldCents` (reserved by running jobs), `availableCents` (what new jobs can use), `payAsYouGo`, `monthUsageSeconds` and `tier` (`standard`, or `volume` once you pass 500 hours this month). When credits cannot be read at that moment, `available` is `false` and the other fields are omitted; retry shortly. ## Compared with other APIs List or pay-as-you-go prices, normalised to US dollars per footage-hour, as of 13 October 2026. Each figure links to the provider's pricing page; check there for current numbers. | API | Per footage-hour | What that buys | |---|---:|---| | **FrameQuery**, `all` | **$1.50** | Scene captions, object labels, colour palette, shot type and angle, plus a speaker-labelled transcript, in one job | | **FrameQuery**, `transcript` | **$0.35** | Speaker-labelled transcript | | [AssemblyAI](https://www.assemblyai.com/pricing) | $0.17 to $0.23 | Transcript (Universal-2 or Universal-3.5 Pro, plus $0.02/h for speaker labels) | | [Deepgram](https://deepgram.com/pricing) | $0.26 | Transcript (Nova-3, speaker labels included) | | [Rev.ai](https://www.rev.ai/pricing) | $0.20 | Transcript (Reverb, English; $0.30 other languages) | | [Gladia](https://www.gladia.io/pricing) | $0.61 | Transcript (Starter list price) | | [Twelve Labs](https://www.twelvelabs.io/pricing) | $4.34 + output tokens | Indexing ($2.50/h plus $0.09/h/month storage) and Pegasus analysis ($1.75/h plus $7.50 per million output tokens) | | [Google Video Intelligence](https://cloud.google.com/video-intelligence/pricing) | $8.88 | Label detection ($0.10/min, shot detection included) plus speech transcription ($0.048/min), per-minute rounding | | [Azure Video Indexer](https://azure.microsoft.com/en-us/pricing/details/video-indexer/) | Not published | | Where we are cheaper and where we are not: - **Video understanding** (scenes plus transcript) costs about a third of Twelve Labs' indexing plus analysis and about a sixth of Google's label, shot and speech features. - **Transcription only** costs more with us than with AssemblyAI, Deepgram or Rev.ai, and less than Gladia's list price. If you only need transcripts at the lowest price, those are good choices. Our transcript makes most sense alongside scenes, or when you need camera RAW or very large files handled without transcoding. - Our object labels are labels, not bounding boxes, and the beta API does not include search. --- # Errors > Every API error code with its HTTP status, what it means, what to do and whether to retry. Plus the job and stage failure codes. Source: https://www.framequery.com/docs/errors (updated 2026-10-13) ## The error envelope Every error response has the same shape: ```json { "error": "callbackUrl must be a public HTTPS URL.", "code": "INVALID_CALLBACK_URL", "docUrl": "https://www.framequery.com/docs/errors#INVALID_CALLBACK_URL", "param": "callbackUrl", "requestId": "3c9a7e1f5b2d4068" } ``` | Field | Notes | |---|---| | `code` | Stable identifier. Branch on this | | `error` | Human-readable message. It can change; do not parse it | | `docUrl` | This page, at the code's anchor | | `param` | The request field or query parameter at fault, as a JSON path (`fileName`, `audioTracks[2].offsetMs`, `clips[3].sourceUrl`, `metadata.assetId`, `cursor`). Present only when one field is to blame | | `requestId` | Also in the `X-Request-Id` response header. Quote it to support | Some codes add fields, listed below (for example `state` and `reason` on `RESULTS_UNAVAILABLE`, `maxUploadBytes` on `FILE_TOO_LARGE`). ## When to retry - **Retry** every `429` and `503`, and `409` `IDEMPOTENCY_IN_PROGRESS` and `RESULTS_NOT_READY`. All of them carry `Retry-After` in seconds: wait that long, then retry, reusing the same `Idempotency-Key` on creates. - **Back off and retry** `500 INTERNAL_ERROR` (exponential backoff, same idempotency key). It has no `Retry-After`. - **Do not retry** anything else unchanged. Every other `4xx` is final for that request: fix it first. The SDKs follow these rules automatically, honouring `Retry-After` up to 60 seconds. ## Summary | Code | HTTP | Retry? | |---|---|---| | [`INVALID_REQUEST`](#INVALID_REQUEST) | 400 | No | | [`INVALID_URL`](#INVALID_URL) | 400 | No | | [`URL_NOT_ALLOWED`](#URL_NOT_ALLOWED) | 400 | No | | [`INVALID_CALLBACK_URL`](#INVALID_CALLBACK_URL) | 400 | No | | [`UNAUTHORIZED`](#UNAUTHORIZED) | 401 | No | | [`INSUFFICIENT_CREDITS`](#INSUFFICIENT_CREDITS) | 402 | After topping up | | [`INSUFFICIENT_SCOPE`](#INSUFFICIENT_SCOPE) | 403 | No (reserved) | | [`ACCOUNT_SUSPENDED`](#ACCOUNT_SUSPENDED) | 403 | No | | [`NOT_FOUND`](#NOT_FOUND) | 404 | No | | [`RESULTS_UNAVAILABLE`](#RESULTS_UNAVAILABLE) | 404 | No, never | | [`JOB_NOT_CANCELLABLE`](#JOB_NOT_CANCELLABLE) | 409 | No | | [`IDEMPOTENCY_IN_PROGRESS`](#IDEMPOTENCY_IN_PROGRESS) | 409 | Yes, after `Retry-After` (2 s) | | [`RESULTS_NOT_READY`](#RESULTS_NOT_READY) | 409 | Yes, after `Retry-After` (15 s) | | [`FILE_TOO_LARGE`](#FILE_TOO_LARGE) | 413 | No | | [`UNSUPPORTED_FORMAT`](#UNSUPPORTED_FORMAT) | 415 | No | | [`IDEMPOTENCY_BODY_MISMATCH`](#IDEMPOTENCY_BODY_MISMATCH) | 422 | No | | [`RATE_LIMITED`](#RATE_LIMITED) | 429 | Yes, after `Retry-After` | | [`TOO_MANY_ACTIVE_JOBS`](#TOO_MANY_ACTIVE_JOBS) | 429 | Yes, after `Retry-After` (30 s) | | [`THROTTLED`](#THROTTLED) | 429 | Not in practice (`Retry-After` 3600 s) | | [`INTERNAL_ERROR`](#INTERNAL_ERROR) | 500 | Yes, with backoff | | [`SERVICE_UNAVAILABLE`](#SERVICE_UNAVAILABLE) | 503 | Yes, after `Retry-After` (30 s) | ## Request errors (400) ### INVALID_REQUEST `400`, not retryable. The body or a query parameter is malformed, missing, or out of range: a missing `fileName`, an unknown `processingMode`, metadata over the limits, a bad `cursor`, `state` and `status` filters combined, `track` missing on a multi-track SRT request, an `idempotencyKey` body field that differs from the `Idempotency-Key` header. `param` names the field. Fix it and send a new request. ### INVALID_URL `400`, not retryable. A media URL (`url`, `audioUrl`, `audioTracks[].url`, `clips[].sourceUrl`) is not an absolute `http` or `https` URL. Send the full URL including the scheme. ### URL_NOT_ALLOWED `400`, not retryable. The URL points to a private, loopback, link-local or cloud-metadata address, or an internal host name. We only fetch from the public internet. Use a publicly reachable URL, such as a presigned link to your object storage. ### INVALID_CALLBACK_URL `400`, not retryable. `callbackUrl` (or the `url` of `POST /webhooks/test`) is not a public HTTPS URL on port 443, without credentials and at most 2,048 characters. Use `https://` on a public host and the default port; plain HTTP, other ports, `user:pass@` URLs and private addresses are refused. ## Authentication and account (401, 402, 403) ### UNAUTHORIZED `401`, not retryable. The API key is missing, malformed, revoked, rotated or expired. Check that `FRAMEQUERY_API_KEY` holds a current `fq_live_` key and that you send it as `Authorization: Bearer ` or `X-API-Key: `. After a rotation the old key stops working within about a minute. See [Authentication](https://www.framequery.com/docs/authentication). ### INSUFFICIENT_CREDITS `402`, retry after topping up. Your available credits (balance minus holds for running jobs) do not cover even the 30-second minimum for this job. Buy credits on the [API keys page](https://www.framequery.com/profile/api-keys) and check `GET /quota`. The same code appears as a **job** error when the balance cannot cover the full file once its duration is known; that job is not charged. ### INSUFFICIENT_SCOPE `403`. Reserved for scoped keys. Keys have no scopes during the beta, so the API does not return it today. ### ACCOUNT_SUSPENDED `403`, not retryable. The account is suspended. Contact support@framequery.com. ## Not found (404) ### NOT_FOUND `404`, not retryable. No such job, track or key for this account. Jobs of other accounts look exactly like missing ones, and an id that is not a UUID is also `404`. Check the id and that the key belongs to the account that created the job. ### RESULTS_UNAVAILABLE `404`, never retry. The requested result does not exist and never will: the stage was skipped or failed, the audio track failed, or the job was cancelled, expired or failed before the stage ran. The body adds `state`, `stage`, `stageStatus`, `reason` (`processing_mode`, `audio_only_input`, `no_audio`, `stage_skipped`, `stage_failed`, `track_failed`, `job_cancelled`, `job_expired`, `job_failed`), and `errorCode` or `trackIndex` when relevant. Stop polling for this result. See [Results](https://www.framequery.com/docs/results#not-ready-versus-unavailable). ## Conflicts (409, 422) ### JOB_NOT_CANCELLABLE `409`, not retryable. The job is already `completed`, `failed`, `cancelled` or `expired`. The body adds `jobId` and `state`. Nothing to do. ### IDEMPOTENCY_IN_PROGRESS `409`, retryable. An earlier request with the same idempotency key is still running. Wait `Retry-After` (2 seconds) and retry with the same key and body; you will get the original response. ### RESULTS_NOT_READY `409`, retryable. The stage is `pending` or `running` and may still complete. The body adds `state`, `stage` and `stageStatus`. Retry after `Retry-After` (15 seconds) or wait for the webhook. ### IDEMPOTENCY_BODY_MISMATCH `422`, not retryable. The idempotency key was already used, within the last 24 hours, with a different request body. Use a new key for each distinct request. ## File problems (413, 415) ### FILE_TOO_LARGE `413`, not retryable. `fileSizeBytes` is over the limit for the file's tier (20 GiB standard, 100 GiB camera RAW). The body adds `maxUploadBytes` and `formatTier`, and `param` is `fileSizeBytes`. Split the file, or [ask us](https://www.framequery.com/contact) about a higher limit. ### UNSUPPORTED_FORMAT `415`, not retryable. The file type is blocked (executables and scripts), or it is a camera RAW format that is not enabled yet. Check `GET /formats`. See [Formats and limits](https://www.framequery.com/docs/formats). ## Rate limits (429) ### RATE_LIMITED `429`, retryable. The account went over a per-minute limit (600 GETs, 30 job creations, 120 other requests), or a client IP went over 120 requests per minute on the keyless endpoints. Wait `Retry-After` seconds. `X-RateLimit-Reset` is when the window resets, as a Unix timestamp. See [Rate limits](https://www.framequery.com/docs/rate-limits). ### TOO_MANY_ACTIVE_JOBS `429`, retryable. The account has too many unfinished jobs. The body adds `maxActiveJobs` and `activeJobs`. Wait `Retry-After` (30 seconds) or for running jobs to finish, then retry. [Ask us](https://www.framequery.com/contact) if you need a higher limit. ### THROTTLED `429`. Job creation is temporarily restricted for this account, with `Retry-After: 3600`. The SDKs do not wait that long. Contact support@framequery.com if it persists. ## Server errors (500, 503) ### INTERNAL_ERROR `500`, retry with backoff. Something failed on our side. Retry with exponential backoff and the same idempotency key, so a create that did succeed is replayed rather than duplicated. If it persists, send us the `requestId`. ### SERVICE_UNAVAILABLE `503`, retryable. A dependency is briefly unavailable, for example your credit balance could not be verified. Retry after `Retry-After` (30 seconds). ## Job and stage error codes These are not HTTP errors. They appear in a job's `error.code` when its `state` is `failed`, and in `stages..error.code` for a stage that failed. A failed stage is never charged; other stages that completed are charged and stay readable. | Code | Stage | Meaning | What to do | |---|---|---|---| | `VALIDATION_FAILED` | ingest | Unsupported or corrupt media, or a limit (such as duration) exceeded | Check the file plays and is within [limits](https://www.framequery.com/docs/formats#limits) | | `INSUFFICIENT_CREDITS` | ingest | The balance could not cover the full file once its duration was known | Buy credits, then create a new job | | `FETCH_FAILED` | ingest | The source URL could not be downloaded (unreachable, an HTTP error, an expired link or token) | Check the URL works from the public internet, then create a new job | | `INGEST_FAILED` | ingest | The file could not be processed | Create a new job once; if it fails again, contact us with the `jobId` | | `EXTERNAL_AUDIO_FAILED` | ingest | A separate audio file could not be processed | Check the audio file and its URL | | `VISION_FAILED` | vision | Scene analysis failed | Resubmit with `processingMode: "vision"` | | `CONTENT_BLOCKED` | vision | The content is not permitted by the acceptable-use policy | See our [terms](https://www.framequery.com/terms) | | `TRANSCRIPT_FAILED` | transcript | Transcription failed | Resubmit with `processingMode: "transcript"` | | `PROCESSING_FAILED` | any | Another failure | Resubmit; contact us if it repeats | | `CANCELLED` | any | The stage was skipped because you cancelled the job, or because another stage failed first | Nothing for a cancel; otherwise see the other stage's code | | `EXPIRED` | any | The job expired before the stage ran | Create a new job | Resubmitting a single mode after a partial failure means you only pay for the stage you are missing. --- # Rate limits > Per-account request limits per minute, the active-job cap, the rate-limit headers and how to back off. Source: https://www.framequery.com/docs/rate-limits (updated 2026-10-13) ## Limits Limits apply per account: every key of the account draws from the same buckets. Windows are one minute. | Bucket | Limit | Counts | |---|---:|---| | Reads | 600 / minute | Every `GET` with a key: jobs, results, quota, delivery log | | Job creation | 30 / minute | `POST /jobs`, `POST /jobs/from-url`, `POST /jobs/batch` (one request, whatever its clip count) | | Other writes | 120 / minute | Everything else, for example `POST /jobs/{jobId}/cancel` | | Keyless discovery | 120 / minute per client IP | `GET /openapi.json`, `/pricing`, `/pricing/estimate`, `/formats` | | Webhook test | 5 / minute | `POST /webhooks/test` | Discovery responses are also cacheable (`Cache-Control: public, max-age=300`), so cache them rather than calling them per job. ## Active jobs Separately from request rates, an account can have a limited number of unfinished jobs at once (50 by default). Creating more returns `429` [`TOO_MANY_ACTIVE_JOBS`](https://www.framequery.com/docs/errors#TOO_MANY_ACTIVE_JOBS) with `Retry-After: 30` and a body that includes `maxActiveJobs` and `activeJobs`. Each clip of an independent batch counts as one job. If your workload needs more concurrency, [contact us](https://www.framequery.com/contact). ## Headers Every response to a request that passed key authentication (success or error), and every discovery response, carries: | Header | Meaning | |---|---| | `X-RateLimit-Limit` | Requests allowed per window in the bucket this request was charged to | | `X-RateLimit-Remaining` | Requests left in the current window | | `X-RateLimit-Reset` | When the window resets, as a **Unix timestamp in seconds** (not a number of seconds to wait) | | `Retry-After` | On `429`: seconds to wait before retrying | ```http HTTP/1.1 429 Too Many Requests Retry-After: 12 X-RateLimit-Limit: 30 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1760356800 ``` ## Backing off On `429` (and `503`), wait exactly `Retry-After` seconds and retry the same request, with the same `Idempotency-Key` for creates. Without a `Retry-After` (a `500`), use exponential backoff with jitter. ```python import random, time import httpx def request_with_retries(client: httpx.Client, method: str, url: str, **kwargs) -> httpx.Response: for attempt in range(5): resp = client.request(method, url, **kwargs) if resp.status_code in (429, 503) or (resp.status_code == 409 and "Retry-After" in resp.headers): time.sleep(float(resp.headers.get("Retry-After", "1"))) continue if resp.status_code >= 500: time.sleep(min(8, 0.5 * 2 ** attempt) * (1 - 0.25 * random.random())) continue return resp return resp ``` The SDKs do this for you, honouring `Retry-After` up to 60 seconds. `THROTTLED` (`Retry-After: 3600`) is not something to wait out in a loop; see [Errors](https://www.framequery.com/docs/errors#THROTTLED). ## Staying under the limits - Poll with `GET /jobs/{jobId}?include=none` every 5 to 15 seconds, not faster, or use [webhooks](https://www.framequery.com/docs/webhooks). - For many URLs, one `POST /jobs/batch` with up to 100 clips uses one job-creation request. - To list work, page through `GET /jobs?state=...` rather than polling each job. --- # API reference > Every endpoint of the FrameQuery API v1 with its parameters, a copy-pasteable curl command and the shape it returns. The OpenAPI 3.1 document is the machine-readable version. Source: https://www.framequery.com/docs/api (updated 2026-10-13) Base URL `https://api.framequery.com/v1/api`. The machine-readable contract is the OpenAPI 3.1 document at [https://api.framequery.com/v1/api/openapi.json](https://api.framequery.com/v1/api/openapi.json); anchors on this page equal its `operationId`s. All examples assume `export FRAMEQUERY_API_KEY=fq_live_...` and, where a job is involved, `JOB_ID` holding a job id. Successful responses are wrapped as `{"data": …, "requestId": "…"}` (except `GET /openapi.json`); errors follow the [error envelope](https://www.framequery.com/docs/errors). | Method | Path | Auth | Operation | |---|---|---|---| | `POST` | `/jobs` | key | [createJob](#createJob) | | `POST` | `/jobs/from-url` | key | [createJobFromUrl](#createJobFromUrl) | | `POST` | `/jobs/batch` | key | [createBatch](#createBatch) | | `GET` | `/jobs` | key | [listJobs](#listJobs) | | `GET` | `/jobs/{jobId}` | key | [getJob](#getJob) | | `POST` | `/jobs/{jobId}/cancel` | key | [cancelJob](#cancelJob) | | `DELETE` | `/jobs/{jobId}` | key | [cancelJobDeprecated](#cancelJobDeprecated) | | `GET` | `/jobs/{jobId}/transcript` | key | [getTranscript](#getTranscript) | | `GET` | `/jobs/{jobId}/scenes` | key | [getScenes](#getScenes) | | `GET` | `/jobs/{jobId}/audioTracks` | key | [listAudioTracks](#listAudioTracks) | | `GET` | `/jobs/{jobId}/audioTracks/{trackIndex}` | key | [getAudioTrack](#getAudioTrack) | | `GET` | `/quota` | key | [getQuota](#getQuota) | | `GET` | `/webhooks/deliveries` | key | [listWebhookDeliveries](#listWebhookDeliveries) | | `POST` | `/webhooks/test` | key | [sendTestWebhook](#sendTestWebhook) | | `GET` | `/openapi.json` | none | [getOpenApi](#getOpenApi) | | `GET` | `/pricing` | none | [getPricing](#getPricing) | | `GET` | `/pricing/estimate` | none | [estimatePrice](#estimatePrice) | | `GET` | `/formats` | none | [listFormats](#listFormats) | | `GET` | `/keys` | session | [listKeys](#listKeys) | | `GET` | `/keys/portal` | session | [getKeysPortal](#getKeysPortal) | | `POST` | `/keys/{id}/rotate` | session | [rotateKey](#rotateKey) | | `GET` | `/webhooks/secret` | session | [getWebhookSecret](#getWebhookSecret) | | `POST` | `/webhooks/secret/rotate` | session | [rotateWebhookSecret](#rotateWebhookSecret) | **key**: an `fq_live_` API key in `Authorization: Bearer` or `X-API-Key`. **none**: no key, rate-limited per IP. **session**: the FrameQuery dashboard's signed-in session, used by the [API keys page](https://www.framequery.com/profile/api-keys); an API key cannot call these. ## Jobs ### Create a job with an upload `POST /jobs`. Creates a job in state `awaiting_upload` and returns the Job object plus `uploadUrl`, `resumableUploadUrl`, `uploadMethod` (`PUT`), `uploadHeaders`, `expiresInSeconds` (3600) and `maxUploadBytes`; for separate audio also `audioUploadUrl` or `audioTrackUploadUrls`, and `audioUploadHeaders`. Accepts `Idempotency-Key`. Details: [Upload a local file](https://www.framequery.com/docs/jobs#upload). Body: `fileName` (required), `fileSizeBytes`, `processingMode`, `callbackUrl`, `idempotencyKey`, `metadata`, `audioFileName`, `audioSyncMode`, `audioOffsetMs`, `audioTracks[]`. ```bash curl -X POST https://api.framequery.com/v1/api/jobs \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"fileName": "interview.mp4", "fileSizeBytes": 734003200, "processingMode": "all"}' ``` Errors: `400`, `401`, `402`, `403`, `409`, `413`, `415`, `422`, `429`, `500`, `503`. ### Create a job from a URL `POST /jobs/from-url`. Creates a job that fetches its media from a public http(s) URL; returns the Job object in state `queued`. Accepts `Idempotency-Key`. Details: [Create a job from a URL](https://www.framequery.com/docs/jobs#from-url). Body: `url` (required), `fileName`, `downloadToken`, `provider` (`gdrive` \| `dropbox`), `processingMode`, `callbackUrl`, `idempotencyKey`, `metadata`, `audioUrl`, `audioDownloadToken`, `audioSyncMode`, `audioOffsetMs`, `audioTracks[]`. ```bash curl -X POST https://api.framequery.com/v1/api/jobs/from-url \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"url": "https://example.com/interview.mp4", "processingMode": "all", "metadata": {"assetId": "A003_C008"}}' ``` Errors: `400`, `401`, `402`, `403`, `409`, `415`, `422`, `429`, `500`, `503`. ### Create a batch `POST /jobs/batch`. Creates jobs for up to 100 URLs. Returns `{batchId, mode, jobs[]}`, each item a Job object plus its clip `index`. Accepts `Idempotency-Key`. Details: [Batch jobs](https://www.framequery.com/docs/jobs#batch). Body: `mode` (`independent` \| `continuous`, required), `clips[]` (1 to 100, each `{sourceUrl, fileName?, downloadToken?, provider?, metadata?}`, required), `processingMode`, `callbackUrl`, `idempotencyKey`, `metadata`. ```bash curl -X POST https://api.framequery.com/v1/api/jobs/batch \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"mode": "independent", "clips": [{"sourceUrl": "https://example.com/a.mp4"}, {"sourceUrl": "https://example.com/b.mp4"}]}' ``` Errors: `400`, `401`, `402`, `403`, `409`, `415`, `422`, `429`, `500`, `503`. ### List jobs `GET /jobs`. Your API jobs, newest first. Returns `{data: [Job summary], hasMore, nextCursor}` and a `Link: <…>; rel="next"` header while more pages exist. Continue while `hasMore` is `true`; pages can be short. Query: `limit` (1 to 100, default 20), `cursor` (the previous `nextCursor`), `state` (a job state; `uploading` is accepted as an alias of `awaiting_upload`), `status` (deprecated legacy filter, not combinable with `state`). ```bash curl "https://api.framequery.com/v1/api/jobs?state=completed&limit=20" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `400`, `401`, `429`, `500`. ### Get a job `GET /jobs/{jobId}`. The [Job object](https://www.framequery.com/docs/job-lifecycle#the-job-object). Query: `include`, a comma list of `scenes` and `transcript`, or `none`. Default: both, each present only once its stage is `completed`. Use `include=none` when polling. ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID?include=none" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `400`, `401`, `404`, `429`, `500`. ### Cancel a job `POST /jobs/{jobId}/cancel`. No body. Allowed while `awaiting_upload`, `queued` or `processing`; returns the Job object (without `processedData`) and emits `job.cancelled`. Completed stages stay charged and readable. Details: [Cancel a job](https://www.framequery.com/docs/jobs#cancel). ```bash curl -X POST "https://api.framequery.com/v1/api/jobs/$JOB_ID/cancel" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `401`, `404`, `409` (`JOB_NOT_CANCELLABLE`), `429`, `500`. ### Cancel a job (deprecated) `DELETE /jobs/{jobId}`. Deprecated alias of `POST /jobs/{jobId}/cancel` with identical behaviour. Responses carry `Deprecation: true` and `Link: ; rel="successor-version"`. The `DELETE` verb is reserved for a future "delete job and results" operation. ```bash curl -X DELETE "https://api.framequery.com/v1/api/jobs/$JOB_ID" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` ## Results ### Get the transcript `GET /jobs/{jobId}/transcript`. JSON `{jobId, language, speakers, text, segments[], tracks?, trackIndex?, trackName?}`, or a caption file. Details: [Results](https://www.framequery.com/docs/results#transcript). Query: `format` (`json` default, `srt`, `vtt`, `txt`), `track` (audio track index; required for `srt`/`vtt`/`txt` on multi-track jobs). ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/transcript?format=vtt" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" -o transcript.vtt ``` Errors: `400`, `401`, `404` (`NOT_FOUND`, `RESULTS_UNAVAILABLE`), `409` (`RESULTS_NOT_READY`), `500`. ### Get scenes `GET /jobs/{jobId}/scenes`. `{jobId, scenes[]}`, each scene `{index, startTime, endTime, description, objects, colorPalette, shotType, shotAngle}`. Empty when the video has no scene cuts. Details: [Results](https://www.framequery.com/docs/results#scenes). ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/scenes" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `401`, `404` (`NOT_FOUND`, `RESULTS_UNAVAILABLE`), `409` (`RESULTS_NOT_READY`), `500`. ### List audio tracks `GET /jobs/{jobId}/audioTracks`. `{jobId, tracks[]}` for a multi-track job, ordered by index; each `{trackIndex, trackName, status, transcript, speakers, language, errorCode?}` with the stored transcript entries. ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/audioTracks" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `401`, `404`, `500`. ### Get one audio track `GET /jobs/{jobId}/audioTracks/{trackIndex}`. One track, same shape as a `tracks[]` item above. ```bash curl "https://api.framequery.com/v1/api/jobs/$JOB_ID/audioTracks/0" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `400`, `401`, `404`, `500`. ## Account ### Credits and usage `GET /quota`. `data.api` = `{available, balanceCents, heldCents, availableCents, payAsYouGo, monthUsageSeconds, tier, volumeThresholdSeconds}`. When `available` is `false` the balance could not be read right now and the other fields are omitted. The remaining top-level fields describe desktop-app plans and can be ignored. Details: [Credits and billing](https://www.framequery.com/docs/pricing#credits-and-billing). ```bash curl https://api.framequery.com/v1/api/quota \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `401`, `429`. ## Webhooks ### Delivery log `GET /webhooks/deliveries`. The last 100 deliveries, newest first: `{id, jobId, eventType, urlHost, status, attempts, lastStatus, lastError, disabled, deliveredAt, createdAt}`. Query: `jobId`. Details: [Delivery log](https://www.framequery.com/docs/webhooks#deliveries). ```bash curl "https://api.framequery.com/v1/api/webhooks/deliveries?jobId=$JOB_ID" \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" ``` Errors: `401`, `429`. ### Send a test event `POST /webhooks/test`. Sends a signed `webhook.test` event to `url` (HTTPS, port 443, public address; 5 per minute). Returns `{delivered, statusCode, latencyMs, error?}`. ```bash curl -X POST https://api.framequery.com/v1/api/webhooks/test \ -H "Authorization: Bearer $FRAMEQUERY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-app.example.com/webhooks/framequery"}' ``` Errors: `400`, `401`, `429`. ## Discovery (no key) All four are rate-limited to 120 requests per minute per client IP and cacheable for five minutes (`Cache-Control: public, max-age=300`). A request that carries a key is served the same way. ### OpenAPI document `GET /openapi.json`. The OpenAPI 3.1 document as JSON, not wrapped in `data`. ```bash curl https://api.framequery.com/v1/api/openapi.json -o framequery-openapi.json ``` ### Price table `GET /pricing`. Per-stage rates in US cents per footage-hour for the `standard` and `raw` tiers, plus `volumeThresholdSeconds`, `minBillableSeconds`, `currency`, `unit` and `rounding`. Details: [Pricing](https://www.framequery.com/docs/pricing). ```bash curl https://api.framequery.com/v1/api/pricing ``` ### Price estimate `GET /pricing/estimate`. The exact charge for one job if every requested stage completes: `{cents, usd, currency, billableSeconds, durationSeconds, tier, mode, audioTracks, priorUsageSeconds}`. Query: `durationSeconds` (required, 0 to 2 678 400), `tier` (`standard` \| `raw`), `mode` (`all` \| `transcript` \| `vision`), `audioTracks` (1 to 16), `priorUsageSeconds` (billable seconds already used this UTC month). ```bash curl "https://api.framequery.com/v1/api/pricing/estimate?durationSeconds=3600&tier=raw&mode=all" ``` Errors: `400` (with `param`), `429`. ### Formats and limits `GET /formats`. `{standard, audioOnly, raw: {r3d, braw, arri}, rawExtensions, blocked, unknownExtensions, limits: {standardMaxBytes, rawMaxBytes, maxDurationSec, uploadUrlExpirySeconds}}`. Details: [Formats and limits](https://www.framequery.com/docs/formats). ```bash curl https://api.framequery.com/v1/api/formats ``` ## Dashboard (session) These power the [API keys page](https://www.framequery.com/profile/api-keys). They take the dashboard's signed-in session token, not an API key, and are listed here because they are part of the contract. `$FRAMEQUERY_SESSION_TOKEN` below stands for that token. ### List keys `GET /keys`. Your Polar license keys, never the full secret: `[{id, displayKey, status, expiresAt, benefitId, createdAt}]`. ```bash curl https://api.framequery.com/v1/api/keys -H "Authorization: Bearer $FRAMEQUERY_SESSION_TOKEN" ``` ### Customer portal `GET /keys/portal`. `{url}`: a short-lived Polar customer-portal link to view keys, receipts and credits. `404` if you have never bought anything. ```bash curl https://api.framequery.com/v1/api/keys/portal -H "Authorization: Bearer $FRAMEQUERY_SESSION_TOKEN" ``` ### Rotate a key `POST /keys/{id}/rotate`. Rotates one key. The response is the key object plus `key`, the new full secret, shown once. ```bash curl -X POST "https://api.framequery.com/v1/api/keys/$KEY_ID/rotate" -H "Authorization: Bearer $FRAMEQUERY_SESSION_TOKEN" ``` ### Reveal the webhook secret `GET /webhooks/secret`. `{secret, createdAt, rotatedAt}`; the `whsec_` secret is created on the first call. ```bash curl https://api.framequery.com/v1/api/webhooks/secret -H "Authorization: Bearer $FRAMEQUERY_SESSION_TOKEN" ``` ### Rotate the webhook secret `POST /webhooks/secret/rotate`. Returns the new secret. The previous one keeps signing, as a second `v1,` signature, for 24 hours. ```bash curl -X POST https://api.framequery.com/v1/api/webhooks/secret/rotate -H "Authorization: Bearer $FRAMEQUERY_SESSION_TOKEN" ``` --- # SDKs > Official Python and TypeScript SDKs (0.2) with uploads, polling, retries, typed errors and webhook verification. Go, Rust and Ruby SDKs at 0.1. Source: https://www.framequery.com/docs/sdks (updated 2026-10-13) All SDKs are open source in [FrameQuery/framequery-sdks](https://github.com/FrameQuery/framequery-sdks). They follow one design, so method names, field names and error classes match across languages, and they match the API: every field is the JSON name in the language's usual case (`jobId` is `job_id` in Python, `jobId` in TypeScript). | Language | Version | Install | |---|---|---| | Python 3.9+ | 0.2 | `pip install framequery` | | TypeScript / JavaScript (Node 18+, Deno, Bun, edge) | 0.2 | `npm install framequery` | | Go | 0.1 | `go get github.com/framequery/framequery-go` | | Rust | 0.1 | `cargo add framequery` | | Ruby | 0.1 | `gem install framequery` | The Go, Rust and Ruby SDKs are at 0.1 and move to the 0.2 surface next; see their READMEs in the [SDK repository](https://github.com/FrameQuery/framequery-sdks). This page documents 0.2. ## What the SDKs do for you - **Uploads** straight to storage, streamed from disk and never read into memory whole: one `PUT` up to 32 MiB, resumable 16 MiB chunks above that, resumed automatically after network errors. - **Waiting** on `state` with sensible backoff; `wait()` returns the terminal job and never raises for `failed`. - **Retries** on `429`, `503` and connection errors, honouring `Retry-After`, with an idempotency key on every create so a retry never creates a second job. - **Typed errors** carrying `code`, `doc_url` / `docUrl`, `param`, `request_id` / `requestId` and `retry_after` / `retryAfter`. - **Webhook verification** without extra dependencies. ## Client ```python from framequery import FrameQuery, AsyncFrameQuery fq = FrameQuery() # api_key defaults to FRAMEQUERY_API_KEY fq = FrameQuery(api_key="fq_live_...", timeout=60, max_retries=3) async with AsyncFrameQuery() as afq: # same methods, as coroutines job = await afq.jobs.get(job_id) ``` ```typescript import FrameQuery from "framequery"; const fq = new FrameQuery(); // apiKey defaults to FRAMEQUERY_API_KEY const fq2 = new FrameQuery({ apiKey: "fq_live_...", timeout: 60_000, maxRetries: 3 }); ``` Keyless calls (`pricing.get`, `pricing.estimate`, `formats.list`) and webhook verification work without a key. ## Process in one call `process()` creates the job (upload or URL), waits for it, and fetches whichever results completed. ```python result = fq.process("interview.mp4", processing_mode="all", metadata={"assetId": "A003_C008"}) result.job # Job (state "completed") result.transcript # Transcript, or None if that stage was skipped result.scenes # list[Scene], or None if that stage was skipped ``` ```typescript const result = await fq.process("./interview.mp4", { processingMode: "all", metadata: { assetId: "A003_C008" } }); result.job; // Job (state "completed") result.transcript; // Transcript | null result.scenes; // Scene[] | null ``` If the job ends `failed` or `expired`, `process()` raises `JobFailedError` (with `job`, `code` and `stage`); `cancelled` raises `JobCancelledError`. Stages that completed are still readable through `fq.jobs.transcript(...)` and `fq.jobs.scenes(...)`. ## Methods | Operation | Python | TypeScript | |---|---|---| | Create and upload a file | `fq.jobs.create(path_or_file, ...)` | `fq.jobs.create(file, {...})` | | Create, upload elsewhere | `fq.jobs.create_upload(file_name=...)` | `fq.jobs.createUpload({ fileName })` | | Create from a URL | `fq.jobs.create_from_url(url, ...)` | `fq.jobs.createFromUrl(url, {...})` | | Batch | `fq.batches.create(clips, mode=...)` | `fq.batches.create(clips, { mode })` | | Get | `fq.jobs.get(job_id)` | `fq.jobs.get(jobId)` | | List (auto-paging) | `for job in fq.jobs.list(state="completed")` | `for await (const job of fq.jobs.list({ state: "completed" }))` | | Wait | `fq.jobs.wait(job_id, timeout=7200)` (seconds) | `fq.jobs.wait(jobId, { timeout })` (ms) | | Cancel | `fq.jobs.cancel(job_id)` | `fq.jobs.cancel(jobId)` | | Transcript (JSON) | `fq.jobs.transcript(job_id)` | `fq.jobs.transcript(jobId)` | | Transcript (SRT, VTT, TXT) | `fq.jobs.transcript(job_id, format="srt")` | `fq.jobs.transcript(jobId, { format: "srt" })` | | Scenes | `fq.jobs.scenes(job_id)` | `fq.jobs.scenes(jobId)` | | Balance | `fq.quota.get()` | `fq.quota.get()` | | Price table | `fq.pricing.get()` | `fq.pricing.get()` | | Price estimate | `fq.pricing.estimate(duration_seconds, ...)` | `fq.pricing.estimate(durationSeconds, {...})` | | Formats | `fq.formats.list()` | `fq.formats.list()` | | Verify a webhook | `fq.webhooks.verify(payload, headers)` | `await fq.webhooks.verify(payload, headers)` | | Verify and parse | `fq.webhooks.unwrap(payload, headers)` | `await fq.webhooks.unwrap(payload, headers)` | | Delivery log | `fq.webhooks.list_deliveries(job_id=...)` | `fq.webhooks.listDeliveries({ jobId })` | | Test delivery | `fq.webhooks.send_test(url)` | `fq.webhooks.sendTest(url)` | `jobs.get` sends `include=none` by default; results come from `transcript()` and `scenes()`. ## Errors | Class | When | |---|---| | `InvalidRequestError` | `400`, `413`, `415`, `422`; `param` names the field | | `AuthenticationError` | `401`, or no key configured (raised before any request) | | `InsufficientCreditsError` | `402` | | `PermissionDeniedError` | `403` | | `NotFoundError` | `404 NOT_FOUND` | | `ResultsUnavailableError` | `404 RESULTS_UNAVAILABLE`: stop; see `reason` | | `ConflictError` | `409 JOB_NOT_CANCELLABLE`, `IDEMPOTENCY_IN_PROGRESS` | | `ResultsNotReadyError` | `409 RESULTS_NOT_READY`: try later | | `RateLimitError` | `429` | | `InternalServerError` | `5xx` | | `APIConnectionError`, `APITimeoutError` | No HTTP response | | `JobFailedError`, `JobCancelledError`, `WaitTimeoutError` | From `process()` / `wait()` | | `UploadError` | The storage upload failed | | `WebhookVerificationError` | Bad signature, headers or timestamp | All API errors derive from `APIError`, and everything from `FrameQueryError`. ```python from framequery import InsufficientCreditsError, RateLimitError, ResultsNotReadyError try: transcript = fq.jobs.transcript(job_id) except ResultsNotReadyError as err: print("not yet, retry in", err.retry_after, "s") except InsufficientCreditsError as err: print("top up:", err.doc_url) ``` ```typescript import { ResultsNotReadyError, InsufficientCreditsError } from "framequery"; try { const transcript = await fq.jobs.transcript(jobId); } catch (err) { if (err instanceof ResultsNotReadyError) console.log("not yet, retry in", err.retryAfter, "s"); else if (err instanceof InsufficientCreditsError) console.log("top up:", err.docUrl); else throw err; } ``` ## Webhooks See [Webhooks](https://www.framequery.com/docs/webhooks#verify) for complete handlers. The secret defaults to `FRAMEQUERY_WEBHOOK_SECRET`. In Python, `framequery.webhooks.verify(payload, headers, secret)` and `framequery.webhooks.unwrap(...)` also work without a client. Always pass the raw request body. ## Other languages and plain HTTP Any HTTP client works: see the [API reference](https://www.framequery.com/docs/api) for every endpoint, or generate a client from the [OpenAPI document](https://api.framequery.com/v1/api/openapi.json). --- # MCP server > A Model Context Protocol server so Claude, Cursor and other MCP clients can process video with FrameQuery. Coming with SDK 0.2. Source: https://www.framequery.com/docs/mcp (updated 2026-10-13) > **Not released yet.** The FrameQuery MCP server ships with the 0.2 SDKs as `npx @framequery/mcp`. The package is not on npm today, so the commands below will not work until it is. This page describes what is planned. ## What it will do `@framequery/mcp` is a local (stdio) MCP server built on the TypeScript SDK. It lets an assistant such as Claude Desktop, Claude Code or Cursor process a video or audio file and read the results, using your own API key. | Tool | What it does | Costs credits | |---|---|---| | `process_video` | Create a job from a URL or a local file (resumable upload) and wait briefly for it | Yes | | `wait_for_job` | Wait for a job, with progress updates | No | | `get_job` | Read a job's state and stages | No | | `get_transcript` | Read a transcript as compact text, paged by time | No | | `get_scenes` | Read scenes, paged, with selectable fields | No | | `list_jobs` | List recent jobs | No | | `get_balance` | Read your credit balance | No | | `estimate_cost` | Price a job before running it | No | | `cancel_job` | Cancel a running job | No | Planned safety limits: local files only from directories you allow, a per-session spending cap, a maximum job duration, and a read-only mode that hides `process_video` and `cancel_job`. Tool retries reuse a deterministic idempotency key, so an assistant retrying a call does not pay twice. ## Planned configuration The server will read `FRAMEQUERY_API_KEY` from its environment, like the SDKs. For Claude Desktop, Cursor and other clients that use an `mcpServers` block: ```json { "mcpServers": { "framequery": { "command": "npx", "args": ["-y", "@framequery/mcp"], "env": { "FRAMEQUERY_API_KEY": "fq_live_..." } } } } ``` Until it ships, any agent can use the API directly: start with the [Agent quickstart](https://www.framequery.com/docs/agent-quickstart), or point it at [/llms.txt](https://www.framequery.com/llms.txt) and the [OpenAPI document](https://api.framequery.com/v1/api/openapi.json).