# 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 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-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`](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 <first>-<last>/<total>`. 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 */<total>` 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=<nextCursor>` (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`.
