> ## Documentation Index
> Fetch the complete documentation index at: https://docs.clavata.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Video Evaluation

> Evaluate a video against your policy: create a video job, find out when it finishes, and read the timestamped label hits with GetJobLabels.

Moonbounce evaluates a video by splitting it into scenes and checking each scene
against your policy. Each label hit says where in the video it happened. Video
jobs run asynchronously. You create the job, wait for it to finish, then read
the hits.

> [!NOTE]
>
> Video evaluation is in preview. The `video` request field is not yet shown in
> the [Create a new job](https://docs.clavata.ai/api-reference/create-jobs/create-a-new-job.md) reference,
> so this page documents it. Request and response shapes may change before
> general availability.

## How it Works

1. **Create a job** for the video with
   [`POST /v1/jobs`](https://docs.clavata.ai/api-reference/create-jobs/create-a-new-job). The response
   returns a `jobUuid` straight away, with status `JOB_STATUS_PENDING`.
2. **Wait for the job to finish.** It moves through `JOB_STATUS_RUNNING` to
   `JOB_STATUS_COMPLETED` or `JOB_STATUS_FAILED`. Get notified with a webhook,
   or poll.
3. **Read the label hits** with
   [`GET /v1/jobs/{jobUuid}/labels`](https://docs.clavata.ai/api-reference/get-jobs/get-job-label-hits).
   Each hit carries the label, its score, and where it happened in the video.

Every request authenticates with an
[API key](https://docs.clavata.ai/integrations_and_developer_tools/api-docs-sdks/api_key_management.md),
sent as a bearer token:

```http theme={null}
Authorization: Bearer <YOUR_API_KEY>
```

The examples use the HTTP/JSON API, whose field names are camelCase (for
example `jobUuid`). The gRPC and protobuf names are the snake\_case equivalents
(`job_uuid`). In JSON, 64-bit integer fields such as `startMs` are encoded as
**strings**.

HTTP/JSON responses include every top-level field, even when it has no value:
an empty string is `""`, an empty list is `[]`, and an unset object is `null`.
Check values, not whether a key is present.

## Create a Video Job

Put a `video` object in a `contentData` entry. The video must be hosted at a
**publicly accessible HTTPS URL**. Moonbounce downloads it from that URL, and you
can't upload the file bytes directly.

```http theme={null}
POST https://gateway.app.clavata.ai:8443/v1/jobs
Content-Type: application/json
Authorization: Bearer <YOUR_API_KEY>
```

```json theme={null}
{
  "policyId": "<your-policy-id>",
  "contentData": [
    {
      "video": {
        "url": "https://your-bucket.example.com/clip.mp4"
      }
    }
  ],
  "webhook": {
    "url": "https://your-server.example.com/moonbounce/webhook"
  }
}
```

| Field | Required | Notes |
| - | - | - |
| `policyId` | Yes | The policy to evaluate the video against. |
| `contentData[].video.url` | Yes | A public `https://` URL for the video file. |
| `contentData[].video.maxFrames` | No | A ceiling on the number of scenes Moonbounce analyzes. The default and the maximum are both 8,000. Larger values are rejected. If the video has more scenes than this, the job fails with `JOB_ERROR_REASON_VIDEO_SCENE_CEILING_EXCEEDED`. |
| `contentData[].metadata` | No | Key/value pairs stored with the content. |
| `threshold` | No | The score threshold for a label to count as a hit. If you omit it or send `0`, Moonbounce uses the policy's threshold, or 0.5 if the policy doesn't set one. |
| `webhook` | No | Where Moonbounce should notify you when the job finishes. See [Webhook](#webhook). |

The response is the job, still pending. Save `job.jobUuid`.

```json theme={null}
{
  "job": {
    "jobUuid": "5db31421-0ed7-487f-9f8a-126f75fcb89d",
    "status": "JOB_STATUS_PENDING",
    "jobType": "JOB_TYPE_STANDARD",
    "policyId": "<your-policy-id>",
    "policyVersionId": "ec1848f0-550a-4ce4-84f8-0071b7b2cb31",
    "created": "2026-10-05T20:23:30.202381766Z",
    "updated": "2026-10-05T20:23:30.203003782Z"
  }
}
```

### Video requirements

* **One video per request.** A video can't be combined with other content in
  the same job. To evaluate several videos, create a job for each one.
* **Use `POST /v1/jobs`.** The streaming endpoint (`POST /v1/jobs/stream`)
  doesn't accept video.
* **Formats.** The URL must serve one of these content types:

  * `video/mp4` or `application/mp4`
  * `video/webm`
  * `video/quicktime`
  * `video/x-matroska`
  * `video/x-msvideo`
  * `video/mpeg` or `video/MP2T`
  * `video/3gpp` or `video/3gpp2`
  * `video/ogg`
  * `video/x-flv`

  HLS playlists are also accepted, if the URL serves them as
  `application/vnd.apple.mpegurl` or `application/x-mpegurl`. Moonbounce
  decides by the `Content-Type` header, not the `.m3u8` extension, so a
  playlist served as `binary/octet-stream` or `text/plain` fails. The playlist
  must be video-on-demand (not live), unencrypted, and 3 hours or shorter.
* **Size.** Up to 5 GiB.
* **Time.** Downloading a video file can take up to 15 minutes. Retrieving
  and assembling an HLS stream can take up to 60 minutes. If a job is still
  running after 2 hours, it fails with
  `JOB_ERROR_REASON_EXECUTION_DEADLINE_EXCEEDED`.

Moonbounce checks the URL scheme and the request shape when you create the job.
It checks the format, the size, and whether the URL is reachable later, while
the job runs. Those problems show up as a failed job, not as an error on
`POST /v1/jobs`. See [Errors](#errors).

## Know When It's Done

### Webhook

If you set `webhook.url`, Moonbounce sends a `POST` to that URL when the job
completes or fails. That includes jobs that fail a precheck, where
`POST /v1/jobs` itself returned an error (see [Rejected requests](#rejected-requests)),
so you can receive a webhook for a `job_id` you never got back from the API. The URL must be HTTPS. Moonbounce also sends any
`webhook.extraHeaders` you supply, adding an `X-Webhook-` prefix to each header
name so it can't collide with standard headers.

The body names the job and its status, and includes the job itself:

```json theme={null}
{
  "job_id": "5db31421-0ed7-487f-9f8a-126f75fcb89d",
  "status": "completed",
  "job": { "jobUuid": "5db31421-0ed7-487f-9f8a-126f75fcb89d", "status": "JOB_STATUS_COMPLETED" }
}
```

The `job` object here is shortened. The top-level `status` is a short
lowercase value (`completed` or `failed`). The `job.status` inside it uses the
same `JOB_STATUS_*` values as the rest of the API.

The webhook's `job` is not encoded the same way as the
`GET /v1/jobs/{jobUuid}` response. It leaves out fields that are unset or have
default values, and it doesn't include assertion reports. Don't parse both
with code that expects the same keys.

> [!WARNING]
>
> Each webhook is sent once, and Moonbounce waits up to 10 seconds for a
> response. If your endpoint is down, slow, or returns an error, Moonbounce
> doesn't retry. Keep polling as a fallback for any job you haven't
> heard about within the time you expect it to take.

### Polling

Poll [`GET /v1/jobs/{jobUuid}`](https://docs.clavata.ai/api-reference/get-jobs/get-job-details), or call
`GET /v1/jobs/{jobUuid}/labels` directly. Both return the job's current
`status`. Use an increasing interval between polls. Processing takes longer the
longer the video is.

| Status | Meaning |
| - | - |
| `JOB_STATUS_PENDING` | Received, not started yet. |
| `JOB_STATUS_RUNNING` | Downloading, splitting into scenes, transcribing, and evaluating. |
| `JOB_STATUS_COMPLETED` | Done. Label hits are ready. |
| `JOB_STATUS_FAILED` | The job failed. `error` explains why. See [Errors](#errors). |

## Read the Label Hits

```http theme={null}
GET https://gateway.app.clavata.ai:8443/v1/jobs/{jobUuid}/labels
Authorization: Bearer <YOUR_API_KEY>
```

The response lists every label the video hit, with the job's `status`. This
response was requested with `pageSize=3`:

```json theme={null}
{
  "labels": [
    {
      "label": "Fair Use",
      "modality": "MODALITY_VIDEO",
      "score": 0.9669139981269836,
      "startMs": "1017",
      "startTimecode": "00:00:01.017",
      "endMs": "5017",
      "endTimecode": "00:00:05.017",
      "frameStartMs": "1017",
      "frameStartTimecode": "00:00:01.017",
      "frameEndMs": "5017",
      "frameEndTimecode": "00:00:05.017",
      "transcriptStartMs": "31",
      "transcriptStartTimecode": "00:00:00.031",
      "transcriptEndMs": "152210",
      "transcriptEndTimecode": "00:02:32.210"
    },
    {
      "label": "Obscenity and Profanity",
      "modality": "MODALITY_VIDEO",
      "score": 0.9997040629386902,
      "startMs": "1017",
      "startTimecode": "00:00:01.017",
      "endMs": "5017",
      "endTimecode": "00:00:05.017",
      "frameStartMs": "1017",
      "frameStartTimecode": "00:00:01.017",
      "frameEndMs": "5017",
      "frameEndTimecode": "00:00:05.017",
      "transcriptStartMs": "31",
      "transcriptStartTimecode": "00:00:00.031",
      "transcriptEndMs": "152210",
      "transcriptEndTimecode": "00:02:32.210"
    },
    {
      "label": "Fair Use",
      "modality": "MODALITY_VIDEO",
      "score": 0.9099069833755493,
      "startMs": "5017",
      "startTimecode": "00:00:05.017",
      "endMs": "7017",
      "endTimecode": "00:00:07.017",
      "frameStartMs": "5017",
      "frameStartTimecode": "00:00:05.017",
      "frameEndMs": "7017",
      "frameEndTimecode": "00:00:07.017",
      "transcriptStartMs": "31",
      "transcriptStartTimecode": "00:00:00.031",
      "transcriptEndMs": "152210",
      "transcriptEndTimecode": "00:02:32.210"
    }
  ],
  "nextPageToken": "am9ibGFiZWxzLnYxOmFjNzQ2YWZiLTU1NGMtNGNhNC1iNjVjLTUyN2E2Yzk4NzI1MTo6Mw",
  "status": "JOB_STATUS_COMPLETED"
}
```

> [!NOTE]
>
> Label hits appear only once the job is `JOB_STATUS_COMPLETED`. If you call
> this endpoint before then, it returns the current `status` with an empty
> `labels` list, so you can use it to poll.

### How a video is evaluated

Moonbounce splits the video into scenes. Each scene is a frame segment, running
from one detected frame to the next. Where there is speech, Moonbounce pairs
the frame with the overlapping chunk of the audio transcript and evaluates the
two **together**, so the model sees what is shown and what is said at the same
time. Scenes without speech are evaluated from the frame alone. Every hit from a
video job is reported as `MODALITY_VIDEO`, including hits that the transcript
contributed to.

A hit's own range (`startMs` to `endMs`) is the part of the video where the
frame segment and the transcript chunk overlap. For example:

* The frame segment runs `[6000, 15000)` ms.
* The transcript chunk runs `[0, 10000)` ms.
* The hit's range is their overlap, `[6000, 10000)` ms.

A frame segment that spans several transcript chunks is evaluated once with
each chunk. So the same label can come back several times with the same
`frameStartMs`, each hit with its own `startMs`/`endMs`. Don't deduplicate
hits by frame, or you'll drop real hits.

In the response above, the transcript chunk (`[31, 152210)` ms) contains each
frame segment, so each hit's range equals its frame segment. In a scene without
speech, the hit's range is also the whole frame segment, and the transcript
fields are absent.

### Label hit fields

| Field | Notes |
| - | - |
| `label` | The name of the policy label that was hit. |
| `modality` | `MODALITY_VIDEO` for hits from video jobs. See the note on older jobs below. |
| `score` | The score the label received, from 0.0 to 1.0. |
| `startMs` / `endMs` | The hit's range, in milliseconds from the start of the video. |
| `startTimecode` / `endTimecode` | The same range as `HH:MM:SS.mmm`. This is clock time, not frame-based (SMPTE) timecode. |
| `frameStartMs` / `frameEndMs` | The whole frame segment that produced the hit, with matching `frameStartTimecode` / `frameEndTimecode`. |
| `transcriptStartMs` / `transcriptEndMs` | The whole transcript chunk paired with the frame, with matching `transcriptStartTimecode` / `transcriptEndTimecode`. Absent for scenes without speech. |

Hit fields that have no value are left out of the hit; they are never sent as
`null`. This applies only to fields inside a hit. The response's top-level
fields are always present (see [above](#how-it-works)).

> [!NOTE]
>
> Video jobs processed before paired scene evaluation was introduced keep their
> original results. In those jobs, frame hits are `MODALITY_VIDEO` and
> transcript hits are reported separately as `MODALITY_AUDIO`. A frame hit may
> have only `startMs`, and the `frame*` and `transcript*` fields are absent.
> Code that reads older jobs should handle both shapes.

### Ordering and pagination

Hits are returned sorted by `startMs`, then by `label`. Responses are limited to
**1,000** hits. When more remain, `nextPageToken` is set. On the last page it is
an empty string (`""`).

| Query parameter | Notes |
| - | - |
| `pageSize` | The maximum number of hits to return. If you omit it or send `0`, you get 1,000. Values above 1,000 are treated as 1,000. Negative values are rejected. |
| `pageToken` | The `nextPageToken` from the previous response. Omit it to fetch the first page. |
| `jobResultId` | Limits the response to one result of the job. A video job has a single result, so you normally don't need this. |

To read every hit, repeat the request with `pageToken` set to the last
`nextPageToken`, until `nextPageToken` comes back empty. Check for an empty
value, not a missing key: the key is always there, and sending an empty
`pageToken` fetches the first page again.

```javascript theme={null}
let pageToken = "";
const labels = [];
do {
  const url = new URL(`${BASE_URL}/v1/jobs/${jobUuid}/labels`);
  if (pageToken) url.searchParams.set("pageToken", pageToken);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } });
  const page = await res.json();
  labels.push(...(page.labels ?? []));
  pageToken = page.nextPageToken ?? "";
} while (pageToken);
```

Pagination rules:

* **Tokens are tied to the request.** A `pageToken` works only with the same
  `jobUuid` (and the same `jobResultId`, if you set one).
* **Only completed jobs are paginated.** A job that hasn't completed returns an
  empty `nextPageToken`. Sending a `pageToken` for it is rejected.
* **Tokens don't expire.** A completed job's hits never change, so you can
  resume reading later.

## Errors

### Rejected requests

These come back straight away from `POST /v1/jobs`:

| HTTP status | Cause | Job recorded? |
| - | - | - |
| `400 Bad Request` | The video URL isn't `https://` ("Video must be a publicly accessible HTTPS URL"). | No |
| `400 Bad Request` | The request has more than one video, or mixes a video with other content. | No |
| `400 Bad Request` | The video was sent to `POST /v1/jobs/stream` instead of `POST /v1/jobs`. | No |
| `400 Bad Request` | `maxFrames` is above the maximum. | No |
| `429 Too Many Requests` | Your evaluation quota is used up. | Yes, failed with `JOB_ERROR_REASON_QUOTA_EXCEEDED` |
| `499` ("precheck failed") | A check that runs before evaluation blocked the content, or couldn't complete. | Yes, failed with `JOB_ERROR_REASON_CONTENT_BLOCKED` or `JOB_ERROR_REASON_CONTENT_PRECHECK_FAILED` |
| `500 Internal Server Error` | Moonbounce couldn't start processing the video ("failed to start video processing"). The problem is on our side, and retrying usually returns the same error, so contact support. | Yes, failed with `JOB_ERROR_REASON_ADMISSION_FAILED` |

A recorded job appears in [`GET /v1/jobs`](https://docs.clavata.ai/api-reference/get-jobs/list-jobs) as
`JOB_STATUS_FAILED`, but the error response doesn't include its `jobUuid`. If
you set a webhook, it is called for a precheck failure, but not for a quota
failure or a failure to start. Don't assume that an error from `POST /v1/jobs`
means no job exists.

### Failed jobs

Problems found while the job runs mark it `JOB_STATUS_FAILED` and fill in
`error` on the job. For any other status, `error` is `null` in
`GET /v1/jobs/{jobUuid}` and `GET /v1/jobs/{jobUuid}/labels` responses, so
check `status` rather than whether `error` is present. You can read it from `GET /v1/jobs/{jobUuid}`,
`GET /v1/jobs/{jobUuid}/labels`, or the webhook body:

```json theme={null}
{
  "status": "JOB_STATUS_FAILED",
  "error": {
    "reason": "JOB_ERROR_REASON_VIDEO_SOURCE_UNREACHABLE",
    "message": "..."
  }
}
```

Branch on `reason`. The `message` is fixed explanatory text for that reason. It
is safe to show to users, but don't parse it.

| `reason` | What to do |
| - | - |
| `JOB_ERROR_REASON_VIDEO_SOURCE_UNREACHABLE` | Moonbounce couldn't fetch the URL (for example HTTP 403 or 404). Check that the URL is public and hasn't expired. |
| `JOB_ERROR_REASON_VIDEO_VALIDATION_FAILURE` | The file couldn't be validated or decoded. Check the format and size requirements above. |
| `JOB_ERROR_REASON_VIDEO_SCENE_CEILING_EXCEEDED` | The video has more scenes than `maxFrames` allows. Split the video, or raise `maxFrames` up to the maximum. |
| `JOB_ERROR_REASON_HLS_REMUX_TIMEOUT_EXHAUSTED` | An HLS stream couldn't be retrieved in time. Retry, or submit an MP4. |
| `JOB_ERROR_REASON_EXECUTION_DEADLINE_EXCEEDED` | The job ran past its processing deadline. Retry, or split the video. |
| `JOB_ERROR_REASON_ADMISSION_QUEUE_TIMEOUT`, `JOB_ERROR_REASON_ADMISSION_FAILED` | The job couldn't be scheduled. Retry later. |
| `JOB_ERROR_REASON_QUOTA_EXCEEDED` | Your evaluation quota ran out before the job started. |
| `JOB_ERROR_REASON_CONTENT_BLOCKED`, `JOB_ERROR_REASON_CONTENT_PRECHECK_FAILED` | A check that runs before evaluation rejected the content, or couldn't complete. |
| `JOB_ERROR_REASON_TRANSCRIPT_CHUNK_ARTIFACT_INVALID`, `JOB_ERROR_REASON_JOB_FAILED` | Processing failed. Retry, and contact support if it keeps happening. |

New reasons may be added over time. Handle values you don't recognize as a
general failure.

### Reading label hits

| HTTP status | Cause |
| - | - |
| `404 Not Found` | No job with that `jobUuid` in your account, or, once the job has completed, the `jobResultId` isn't part of it. |
| `400 Bad Request` | A `jobUuid` or `jobResultId` that isn't a UUID, a malformed `pageToken`, a token from a different job or scope, a `pageToken` sent for a job that hasn't completed, or a negative `pageSize`. |

A `jobResultId` that isn't part of the job is only reported once the job has
completed. Until then the call returns `200` with the current status, so a poll
with the wrong ID looks healthy.

## Where to Next

> **[Create a new job](https://docs.clavata.ai/api-reference/create-jobs/create-a-new-job.md)**
>
> The full `POST /v1/jobs` request and response schema.

> **[Get job label hits](https://docs.clavata.ai/api-reference/get-jobs/get-job-label-hits.md)**
>
> The `GET /v1/jobs/{jobUuid}/labels` reference.

> **[Get job details](https://docs.clavata.ai/api-reference/get-jobs/get-job-details.md)**
>
> Read a job's status, results, and failure details.

> **[Submit job feedback](https://docs.clavata.ai/api-reference/job-feedback/submit-job-feedback.md)**
>
> Tell Moonbounce whether a hit on a frame or transcript chunk was right.

***

## We'd Love to Hear From You

Whether you have a suggestion, feedback, or a bug to report, here are the best ways to get in touch:

* **In the App:** Use the **Feedback button** for direct suggestions.
* **On Slack:** Reach out to the team in your shared channel.
* **With your AM:** Talk to your dedicated account manager.
* **Via Email:** Send a message to [**support@moonbounce.io**](mailto:support@moonbounce.io).
* **Security, availability, or other incidents:** Use the in-app **Feedback button** or email [**support@moonbounce.io**](mailto:support@moonbounce.io). See [Customer Feedback](https://docs.clavata.ai/feedback/customer_feedback.md) for what to include.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.