API docs · Build
Jobs
Create jobs from a URL, a direct upload or a batch of clips. Processing modes, idempotency, metadata, multi-track audio, listing and cancelling.
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 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
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 |
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 for this job's webhooks |
idempotencyKey | string, ≤ 255 chars | Or the Idempotency-Key header. See Idempotency |
metadata | object | Your own key/value strings, echoed back. See Metadata |
audioUrl, audioDownloadToken, audioSyncMode, audioOffsetMs | One separate audio file. See Multi-track audio | |
audioTracks | array, ≤ 16 | Several separate audio files. See Multi-track audio |
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.
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" }'{
"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-content-length-range": "0,21474836480"
},
"expiresInSeconds": 3600,
"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 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 |
Rules for the upload:
- Send every header in
uploadHeadersverbatim. They are covered by the signature, and the set varies with the request (for example an extrax-goog-meta-*header when you setcallbackUrlor aprocessingModeother thanall), so copy them from the response rather than hard-coding them. A missing or altered header fails with403from Google Cloud Storage. - Do not send your API key, or any
Authorizationheader, to the upload URL. - Both URLs expire after
expiresInSeconds(one hour;uploadExpiresAtis the absolute time). Upload the file once; a job that is never uploaded moves toexpiredabout an hour after it was created. An idempotent replay of the create returns the seconds left, and once the URLs have expired it returnsexpiresInSeconds: 0without the upload fields: read the job, or create a new job under a new idempotency key. - Each upload URL is single-use.
uploadHeaders(andaudioUploadHeaders) includex-goog-if-generation-match: 0, so storage keeps the first completed object and answers412 Precondition Failedto any laterPUTor resumable finalisation on the same URL. If you do not know whether an upload landed, read the job instead of re-uploading: astatepastawaiting_uploadmeans it did; otherwise create a new job. resumableUploadUrlcan be absent. Then use the singlePUT.maxUploadBytesis the size limit for this file's tier: 20 GiB for standard formats, 100 GiB for camera RAW. See Formats and limits.
Single PUT
For files up to a few hundred megabytes, one PUT is simplest:
# 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.movResumable upload
For large files, or unreliable networks, use resumableUploadUrl (a Google Cloud Storage resumable session). An interrupted upload continues from the last byte stored instead of starting over.
- Start the session:
POSTtoresumableUploadUrlwith everyuploadHeadersentry plusx-goog-resumable: startand an empty body. The response is201with aLocationheader: the session URI. PUTthe file to the session URI in chunks that are multiples of 256 KiB, each withContent-Range: bytes <first>-<last>/<total>. GCS answers308with aRangeheader until the last chunk, then200or201.- After a network error, send
PUTwith an empty body andContent-Range: bytes */<total>to the session URI. A308tells you the next byte to send.
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 failureThe 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.
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) oraudioUrl(URL jobs), withaudioSyncModeandaudioOffsetMs. - Several files:
audioTracks, up to 16 entries. Each takesfileName(upload) orurlplus optionaldownloadToken, alabel,syncMode,offsetMs, and for multi-channel filesperChannelTranscriptionandchannels(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.
{
"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.
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-Keyheader or theidempotencyKeybody 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:
422IDEMPOTENCY_BODY_MISMATCH. - Same key while the first request is still running:
409IDEMPOTENCY_IN_PROGRESSwithRetry-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:
"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
metadataon the Job object, inGET /jobslist 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
400INVALID_REQUESTwithparamnaming the field, for examplemetadata.assetId.
Get a job
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, 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
curl "https://api.framequery.com/v1/api/jobs?state=completed&limit=50" \
-H "Authorization: Bearer $FRAMEQUERY_API_KEY"{
"data": [ { "jobId": "0b5e6c1e-…", "state": "completed", "metadata": { "assetId": "A003_C008" } } ],
"hasMore": true,
"nextCursor": "6c1f0d8a-2e4b-4f1a-8c3d-9b7e5a2f1c04",
"requestId": "e4d2a7b1c9f04a36"
}- Newest first.
limitis 1 to 100 (default 20). Filter bystate; the deprecatedstatusfilter still works but cannot be combined withstate. - Fetch the next page with
?cursor=<nextCursor>(and the samestateandlimit). The full next-page URL is also in theLink: <…>; rel="next"header. - A page can hold fewer than
limititems, even zero. Keep going whilehasMoreistrue. - List items carry
stageswithstatusonly,metadataandlinks; get the job for the full object.
Cancel a job
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.
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.