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

# Match music to a video

> Send a clip, get a ranked shortlist of licensed tracks.

Match is the centre of the API. Give it a video you've ingested and it returns a ranked shortlist
of licensed tracks, best first. Every other video workflow takes a `track_id` from here.

## Match an analyzed video

```bash theme={null}
curl https://cue.vibeset.ai/v1/match \
  -H "x-api-key: vbsk_live_..." \
  -H "content-type: application/json" \
  -d '{"video_id": "69e5a58e73370adad5a10dc9", "top_k": 5}'
```

```json 200 theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "lane": {
    "title": "Your shortlist",
    "reasoning": "Driving but unhurried — it carries the cuts without fighting the voice.",
    "matches": [
      { "track_id": "14792", "audio_id": "60184", "soundstripe_song_id": "14792",
        "soundstripe_audio_file_id": "60184", "score": 1.0, "bpm": 120,
        "instrumental": true, "duration_seconds": 164.01 },
      { "track_id": "14857", "audio_id": "60251", "soundstripe_song_id": "14857",
        "soundstripe_audio_file_id": "60251", "score": 0.89, "bpm": 116,
        "instrumental": false, "duration_seconds": 235.38 }
    ]
  }
}
```

`track_id` **is** the Soundstripe song id, and `audio_id` is the Soundstripe audio-file id. Both
are repeated under their explicit `soundstripe_*` names so there is no guessing which catalog they
belong to. Resolve them in your catalog, or pass `track_id` straight to [sync](/docs/sync) or
[loop](/docs/loop).

## The three ways this call succeeds

A `200` is the warm path above. The other two are both `202`, and they carry **different** bodies.
Branch on which keys are present rather than assuming a job. A video that failed to ingest answers
`422` instead, and should be re-submitted.

### The video is not analyzed yet

Cue analyzes each video under one engine per account, warmed at ingest. When that analysis is not
cached for your video, the match runs as a job so it can't trip the gateway timeout:

```json 202 theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "status": "processing",
  "job_id": "run_9f2c…",
  "poll": "/v1/match/jobs/run_9f2c…",
  "next": "Poll the job until status is 'complete', then read its lane + matches."
}
```

Poll `GET /v1/match/jobs/{job_id}` until `status` is `complete`. The finished job returns
`video_id`, `job_id`, `status` and the same `lane` as a synchronous match. Polling is free, and
the match is billed once, on completion.

This is the branch a video hits right after it first turns `ready`. See the note in the
[quickstart](/docs/quickstart).

### The video is still being ingested

If the video's own ingest hasn't finished, you get a `202` with **no `job_id`**, pointing at the
video rather than at a match job:

```json 202 theme={null}
{ "video_id": "69e5a58e73370adad5a10dc9", "status": "processing", "poll": "/v1/videos/69e5a58e73370adad5a10dc9" }
```

Poll `GET /v1/videos/{video_id}` until `status` is `ready`, then call `/v1/match` again.

## Ingest first, match many times

Analysis is the expensive step and it happens once. `POST /v1/videos` analyzes a clip and returns
a `video_id`, and every match, sync and loop against that video afterwards reuses it. See the
[quickstart](/docs/quickstart) for the ingest call.

You can also skip the two-step and pass a `video_url` directly to `/v1/match`. Cue starts the
ingest and hands you the video, not a match job:

```json 202 theme={null}
{
  "video_id": "69e5a58e73370adad5a10dc9",
  "status": "processing",
  "poll": "/v1/videos/69e5a58e73370adad5a10dc9",
  "next": "When status is 'ready', POST /v1/match with this video_id."
}
```

There is no `job_id` on this path and nothing to poll at `/v1/match/jobs/`. Poll the video, then
call `/v1/match` with the `video_id` and handle whichever of the three answers above comes back.

## Steering the shortlist

<ParamField path="top_k" type="integer" default="20">
  **Maximum** number of tracks to return, 1–100. The lane is planned as a curated shortlist of up
  to **10**, so asking for more than that returns everything there is rather than more. Asking for
  fewer returns exactly that many.
</ParamField>

<ParamField path="creative_direction" type="string">
  A plain-language brief, e.g. "warm, cinematic, no vocals". Up to 500 characters.
</ParamField>

<ParamField path="mood" type="string">
  A single mood, e.g. "uplifting" or "tense".
</ParamField>

<ParamField path="era" type="string">
  A period steer, e.g. "80s".
</ParamField>

<ParamField path="vocals" type="string">
  Whether you want vocals, and what kind.
</ParamField>

<ParamField path="use_video_bpm" type="boolean" default="true">
  Weight matches toward the tempo Cue detected in the video. Turn it off when the cut rate
  shouldn't drive the tempo.
</ParamField>

## Sync points inline

Add `"include_sync": true` and every track in the response carries two extra fields,
`sync_status` and `sync_points`. It's included in the match price, and it's off by default so the
plain call stays lean.

```bash theme={null}
curl https://cue.vibeset.ai/v1/match \
  -H "x-api-key: vbsk_live_..." \
  -H "content-type: application/json" \
  -d '{"video_id": "69e5a58e73370adad5a10dc9", "top_k": 5, "include_sync": true}'
```

<Warning>
  **Embedded sync reads the cache and never builds it.** A track whose sync profile isn't indexed
  yet comes back `"sync_status": "warming"` with `"sync_points": []`. On a video you just
  ingested that is most of the shortlist, so plan for an empty array on the first call.
</Warning>

Cue kicks off a background index for every `warming` track it saw, so calling again a moment
later usually returns them `warm`. Read `sync_status` per track:

| `sync_status` | What it means                                                                      |
| ------------- | ---------------------------------------------------------------------------------- |
| `warm`        | `sync_points` is populated.                                                        |
| `warming`     | This pairing isn't indexed yet. `sync_points` is `[]`. Call again shortly.         |
| `unavailable` | Sync couldn't be read at all. `sync_points` is `[]`.                               |
| `error`       | Something failed on our side. `sync_points` is `[]`, and a retry may well succeed. |

`error` exists to keep an outage from reading as a settled fact about your track, so don't treat
the two as interchangeable when you decide whether to pick a different track.

`sync_points_per_track` sets how many points you get per track when they are warm (1–12, default
4\). For one track in depth, or to force the index to be built, use [`/v1/align`](/docs/sync). That
endpoint does build a cold profile, as a job.

## The response shape

The default response is lean: identifiers, score, tempo, whether it's instrumental, and duration.
`?expand=full` swaps in the full public track object, with title, artist, tags, energy and musical
key, and adds a `candidate_pool` array to the lane.

Responses carry identity and analysis, not audio. See
[Render under your own license](/docs/own-license#preview-audio).

## What it costs

\*\*$0.005 per call**, whether it answers `200` or completes as a job, and whether or not you asked
for `include_sync`. Analysis is billed separately, once, when you ingest the video, at $0.06 per
minute of its length. However many times you match against that video afterwards, you pay only the
flat match price. A call that errors bills nothing. See
[Metering & pricing](/docs/metering-and-pricing).

Over MCP the same thing is the [`match_music`](/docs/mcp-tools#match_music) tool.
