# ClipMaster API

Programmatic access to ClipMaster for paid-plan users. The same endpoints the
web app uses, authenticated with an API key. Usage is metered by the same token
system as the app - there is no separate API billing.

## Base URL

```
https://api.clipmaster.app/api/v1
```

## Authentication

Create a key in the web app under Settings -> API access (paid plans only). The
full key (`cm_live_...`) is shown once at creation - store it securely.

Send it as a Bearer token in the Authorization header:

```bash
curl https://api.clipmaster.app/api/v1/projects \
  -H "Authorization: Bearer cm_live_xxxxxxxxxxxxxxxxxxxx"
```

- Header only. Keys are not accepted as a `?token=` query parameter.
- A key resolves to your user account - every endpoint below behaves exactly as
  it does in the app, scoped to your own projects and clips.
- Revoke a key any time in Settings; a revoked key stops working immediately.
- Per-key rate limit: 120 requests/minute (429 with `Retry-After` when exceeded).
  The AI endpoints (suggestions, Auto-edit, audio measurement, arranging) also
  have their own per-user limits and answer 429 the same way.

Account-management routes stay session-only (a key returns
`403 session_auth_required`): login/OAuth, billing and checkout (reading your
token balance and usage is the exception), admin, API-key management, and
connecting a social channel. Connect your channel in the web app first;
everything else is scriptable.

Some endpoints are paid plan features. They are marked below with the feature
name (for example `compilation`); on a plan without it they answer
`403 subscription_required` with that `feature`.

## Quickstart

Set these once in your shell:

```bash
KEY="cm_live_xxxxxxxxxxxxxxxxxxxx"
BASE="https://api.clipmaster.app/api/v1"
```

### 1. Ingest a video by URL

```bash
curl -X POST "$BASE/upload/from-url" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d @body.json
```

Request body:

```json
{
  "source_url": "https://youtube.com/watch?v=..."
}
```

Response:

```json
{
  "project_id": 123,
  "task_id": "a1b2c3d4-5e6f-7890-abcd-ef1234567890",
  "message": "Processing started",
  "progress_url": "/api/v1/projects/123/status"
}
```

### 2. Poll status until completed

```bash
curl "$BASE/projects/123/status" -H "Authorization: Bearer $KEY"
```

Response:

```json
{
  "project_id": 123,
  "status": "completed",
  "progress": 1.0,
  "progress_percent": 100,
  "total_clips": 6,
  "processing_complete": true
}
```

### 3. List the generated clips

```bash
curl "$BASE/projects/123/clips" -H "Authorization: Bearer $KEY"
```

Response:

```json
[
  {
    "clip_id": 456,
    "start_time": 0.0,
    "end_time": 42.3,
    "description": "Founder explains the pivot",
    "status": "ready"
  }
]
```

### 4. Export a clip, then download it

A download is always an export: the clip rendered with its saved captions,
effects and trim.

```bash
# Render it (charges tokens) -> {"export_id": 37, "status": "rendering", ...}
curl -X POST "$BASE/projects/123/clips/456/export" -H "Authorization: Bearer $KEY"

# Poll until "status" is "completed"
curl "$BASE/exports/37/status" -H "Authorization: Bearer $KEY"

# The clip's exports, newest first
curl "$BASE/exports?clip_id=456" -H "Authorization: Bearer $KEY"

# Download from the newest completed export's download_url (no auth header)
curl -L "$DOWNLOAD_URL" -o clip.mp4
```

`download_url` is a presigned S3 link, valid until the next 00:00 UTC (at
least 30 minutes). The file itself stays; ask `GET /exports` again for a
fresh link.

## Upload

For files use the two-step upload flow; for YouTube, ingest by URL. Both take
the same optional fields: `processing_mode` (`automatic` finds every moment,
`semantic` finds what `query` describes, `full_video` keeps the whole video as
one clip), `query`, `min_duration`, `tags`, `orientation`, `include_captions`,
`karaoke_captions`, `karaoke_color`, and `compilation_format`
(`auto | custom | best_of | countdown | trailer | hook_loop | tutorial | qa_highlights`,
paid: `compilation`) with `compilation_prompt` (required by `custom`) to build a
compilation from the clips as soon as they are ready. Processing spends 1 token
per source minute.

### POST /upload/from-url

Ingest a YouTube URL. Paid: `youtube_import`. Request body:

```json
{
  "source_url": "https://youtube.com/watch?v=...",
  "orientation": "vertical",
  "compilation_format": "auto"
}
```

Response: see Quickstart step 1.

### POST /uploads/init

Step 1 of a file upload. POST the file to `upload.url` with the returned
`upload.fields` unchanged, then call complete. Request body:

```json
{
  "filename": "talk.mp4",
  "size_bytes": 94371840,
  "sha256": "e3b0c44298fc1c14...",
  "duration_sec": 693.5,
  "content_type": "video/mp4"
}
```

Response:

```json
{
  "project_id": 124,
  "upload": {
    "url": "https://uploads.clipmaster.app",
    "fields": {
      "...": "opaque form fields - send them back unchanged"
    }
  },
  "expires_at": "2026-08-14T21:00:00Z"
}
```

### POST /uploads/{project_id}/complete

Confirm the upload finished and start processing. No body required.

### POST /uploads/assets

Upload a file that isn't a source video without a browser: a logo, a
voiceover, or a file to play as a compilation scene. `purpose` is one of:

- `logo` - PNG, JPEG or WebP under 2 MB
- `narration` - MP3, M4A or WAV under 25 MB
- `scene_video` - MP4, MOV or WebM under 200 MB (paid: `compilation`)
- `scene_image` - PNG, JPEG or WebP under 10 MB (paid: `compilation`)

Request body:

```json
{ "purpose": "logo", "content_type": "image/png", "size_bytes": 20480 }
```

The response carries an `asset_key` and a presigned POST: send `upload.fields`,
then the file last, to `upload.url` within 15 minutes. Then use the file by its
key:

- `POST /users/me/logo/from-upload` `{ "asset_key": "…" }` — the account's logo
- `POST /projects/{id}/clips/{clip_id}/logo/from-upload` `{ "asset_key": "…" }` — one clip's logo
- `POST /compilations/{id}/narration/from-upload` `{ "asset_key": "…" }` — a compilation's voiceover
- `POST /compilations/{id}/media-scenes` `{ "upload_key": "…" }` — a scene that plays the video or image

## Status and clips

### GET /projects/{id}/status

Poll processing progress. `status` is one of `uploading`, `uploaded`,
`processing`, `completed`, `failed`. Response:

```json
{
  "project_id": 123,
  "status": "processing",
  "progress": 0.45,
  "progress_percent": 45,
  "current_step": "Transcription",
  "total_scenes": 8,
  "total_clips": 0,
  "processing_complete": false
}
```

### GET /projects/{id}/clips

List generated clips with AI metadata. `streaming_url` is a presigned link to
the clip's file for previewing, or `null` when the clip has no file. Response:

```json
[
  {
    "clip_id": 456,
    "start_time": 0.0,
    "end_time": 42.3,
    "description": "Founder explains the pivot",
    "display_name": null,
    "status": "ready",
    "source": "Clipmaster",
    "streaming_url": "https://clipmaster-media.s3.amazonaws.com/...mp4?...",
    "features": {
      "scene_id": 2265,
      "clip_duration": 42.3,
      "vision_analysis": {
        "hook": "The moment everything changed",
        "clip_type": "story",
        "confidence": 0.9
      }
    }
  }
]
```

### GET /projects/{id}/clips/{clip_id}

Full detail for one clip - a superset of the list entry: every feature field,
the scene's AI analysis, its padding, `badge` and `virality`. The background
jobs below report here, under `features.vision_analysis`: the layout analysis,
Auto-edit and their status. Every frame, the audio and the words are in
`GET .../analysis` (next section).

### GET /projects

List your projects. Response:

```json
[
  {
    "project_id": 123,
    "filename": "keynote.mp4",
    "status": "completed",
    "created_at": "2026-08-14T20:11:38Z",
    "video_metadata": {
      "duration_sec": 693.5,
      "resolution": "1920x1080",
      "detected_orientation": "horizontal"
    },
    "is_demo": false
  }
]
```

### DELETE /projects/{id}

Delete a project and all its clips, scenes, and files. Response:

```json
{
  "project_id": 123,
  "success": true,
  "message": "Project deleted"
}
```

### POST /projects/{id}/transcription

Transcribe the whole video again, in the background, for a transcript that
came out wrong. Clips with their own edited words keep them. Poll
`GET /projects/{id}/status` for `project_config.transcription_rerun_status`
(`running` -> `completed` | `failed`). No tokens; rate-limited. Response:

```json
{
  "project_id": 123,
  "status": "running",
  "progress_url": "/api/v1/projects/123/status"
}
```

## Analysis

Read back the analysis, as JSON, to place an edit on what it found. Reading
is free and works on the demo project.

Clocks: a clip's documents use the viewer's time - the clock of
`POST .../overlays`, every suggestion and Auto-edit: seconds from the start of
the clip's scene. The clip plays from 0 to `time.duration` (the scene's
length); times before 0 or after it are the padding around the scene, which
overlays can't use (a negative time is refused, a late one clamped). The
moments list keeps only what plays, so each of its `t` can be placed as is;
the full clip analysis still shows the padding, so place only its times from
0 to `time.duration`. Video seconds = `t` + `time.scene_start`. The video's
document uses seconds from the start of the upload. Boxes (faces, panels) are
`{x, y, w, h}` fractions of the frame.

### GET /projects/{id}/clips/{clip_id}/analysis

A clip's whole stored analysis. Query params: `include` - comma-separated
sections, all by default; `onsets=true` adds the audio's onset list
(`[t, strength]`, one per detected hit - large). An unknown section is
`400 unknown_section`. Sections:

- `scene` - the scene's `description`, `hook`, `topic`, `clip_type`, `score`
  and the rest of its AI fields when stored (`action`, `context`,
  `confidence`, …), `merged_from` (the talk and video evidence a merged scene
  was built from), `badge` and `virality`
- `layout` - `cuts` (camera cuts) and `frames`, each `{t, shot, faces, panels}`;
  a panel is `{kind, x, y, w, h, highlighted}`, `kind` one of `webcam_overlay`,
  `screen_or_app`, `gameplay`, `call_tile`, `inserted_media`. Also
  `frame_interval`, `frame_size` and `faces_placeable` (whether overlays can
  follow a face)
- `audio` - `loud` `{t, end, over_db}`, `quiet` `{start, end, level_db}`,
  `beat` `{start, end, tempo_bpm, first_beat, period}`, `singing`
  `{start, end}` (null when not checked), `has_audio`, `median_active_db`
- `transcript` - the clip's `words`, each `{t, end, word, speaker, confidence}`:
  its own caption words when it has them (`source: "clip_captions"`, no
  `confidence`), else the video transcript's (`source: "video_transcript"`).
  Speakers come from the video transcript either way, when the transcriber
  found them. `null` only when there is no transcript at all
- `sound_events` - laughter, applause and music `{t, end, kind, label, speaker, confidence}`
- `video_moments` - what the whole-video vision pass saw in the clip `{start, end, type, score, description}`
- `talk_scenes` - what the transcript pass found worth a clip `{start, end, clip_type, score, hook, topic, description, confidence}`
- `auto_edit` - the stored take `{current, made_at, effects, stickers, sfx, speed}`, each item in its suggestion shape

`layout`, `audio` and `auto_edit` are `null` until they have run;
`sound_events`, `video_moments` and `talk_scenes` are `[]` when nothing was
found. `current: false` means the result no longer matches the clip: its
video file was cut again since (re-processing - a trim alone doesn't change
the file); for `layout` also when the latest run didn't complete; for `audio`
also when the measuring itself has changed since. For `auto_edit` a trim also
makes it `false`, since its times are on the scene's clock; an older take that
didn't record its scene can't tell and reads `current: null` (unknown). Run it
again to refresh. `time` and `analyses` are always there: `analyses` has one entry per
on-demand run (`layout`, `audio_cues`, `auto_edit`) with `status` (`running`,
`completed`, `failed`, `stale` - marked running for over 30 minutes, so it
probably died; check again before restarting it - or `null`, never run),
`error`, `step` (the last step reached; meaningful while running),
`started_at`, `finished_at`, and how to start it (`what`, `rest`, `mcp`,
`cost`). Audio times are converted from the file they were measured on.
Request:

```bash
curl "$BASE/projects/123/clips/456/analysis?include=scene,layout,audio" \
  -H "Authorization: Bearer $KEY"
```

Response:

```json
{
  "project_id": 123,
  "clip_id": 456,
  "scene_id": 2265,
  "time": {
    "clock": "viewer",
    "unit": "seconds",
    "note": "0 = scene start, …",
    "duration": 10.0,
    "scene_start": 100.0,
    "scene_end": 110.0,
    "clip_start": -2.0,
    "clip_end": 12.0
  },
  "analyses": {
    "layout": {
      "status": "completed",
      "error": null,
      "step": null,
      "started_at": "2026-10-22T18:02:11+00:00",
      "finished_at": "2026-10-22T18:02:40+00:00",
      "what": "Faces, screen/webcam/gameplay panels, shot type per frame, camera cuts.",
      "rest": "POST /api/v1/projects/123/clips/456/layout-analysis",
      "mcp": "analyze_clip_layout",
      "cost": "Paid plans (smart crop); no tokens."
    },
    "audio_cues": { "status": "completed", "...": "same fields" },
    "auto_edit": { "status": null, "...": "same fields" }
  },
  "scene": {
    "description": "He grabs the rim and the crowd erupts",
    "hook": "The dunk nobody saw coming",
    "topic": "basketball",
    "clip_type": "reaction",
    "score": 8,
    "...": "the rest of its stored AI fields",
    "badge": { "label": "Highlight", "key": "highlight", "color": "#FF6B6B" },
    "virality": { "tier": "hot", "label": "Hot", "color": "#FF4444" }
  },
  "layout": {
    "current": true,
    "faces_placeable": true,
    "analyzed_at": "2026-10-22T18:02:40+00:00",
    "model": "gemini-3.5-flash-lite",
    "frame_interval": 2.0,
    "frame_size": { "width": 1920, "height": 1080 },
    "cuts": [4.0],
    "frames": [
      { "t": 0.0, "shot": "solo_talking_head",
        "faces": [{ "x": 0.4, "y": 0.1, "w": 0.2, "h": 0.4 }], "panels": [] },
      { "t": 4.0, "shot": "screen_share_with_webcam",
        "faces": [{ "x": 0.8, "y": 0.04, "w": 0.12, "h": 0.18 }],
        "panels": [{ "kind": "webcam_overlay", "x": 0.75, "y": 0.0, "w": 0.25, "h": 0.25, "highlighted": true }] }
    ]
  },
  "audio": {
    "current": true,
    "measured_at": "2026-10-22T18:01:05+00:00",
    "has_audio": true,
    "median_active_db": -20.0,
    "loud": [{ "t": 4.0, "end": 4.5, "over_db": 9.0 }],
    "quiet": [{ "start": -2.0, "end": -0.5, "level_db": -48.0 }],
    "beat": [{ "start": 0.0, "end": 10.0, "tempo_bpm": 120.0, "first_beat": 0.1, "period": 0.5 }],
    "singing": null
  }
}
```

### GET /projects/{id}/clips/{clip_id}/moments

The clip as one time-sorted list, built for deciding where an edit goes. Each
entry is `{t, kind, source, ...}`:

- `cut` (`layout`) - a camera cut
- `shot` (`layout`, `detail`) and `faces` (`layout`, `count`) - when the shot
  type or the face count changes
- `speaker` (`transcript`, `speaker`, `first_word`) - a speaker turn
- `sound` (`transcript`, `end`, `detail`) - laughter, applause, music
- `loud` (`audio`, `end`, `over_db`), `quiet` (`audio`, `end`), `beat`
  (`audio`, `end`, `tempo_bpm`), `singing` (`audio`, `end`)
- `moment` (`video`, `end`, `detail`, `score`, `description`) - the vision pass's moments

Only what plays is listed, so every `t` can go to `POST .../overlays` as is:
spans are cut to 0..`time.duration`, a `shot`, `faces` or `speaker` set in the
padding before the scene carries over to `t: 0`, and anything else in the
padding is dropped. A layout or audio analysis with `current: false` is left
out and named in `stale` (run it again). Also `time` and `analyses` as in
`.../analysis`, the scene's `summary` (`description`, `hook`, `topic`,
`clip_type`, `score`) and `available` (which analyses have usable data). Same
clock as the clip analysis. Response:

```json
{
  "project_id": 123,
  "clip_id": 456,
  "time": { "clock": "viewer", "duration": 10.0, "...": "as in .../analysis" },
  "summary": {
    "description": "He grabs the rim and the crowd erupts",
    "hook": "The dunk nobody saw coming",
    "topic": "basketball",
    "clip_type": "reaction",
    "score": 8
  },
  "available": { "layout": true, "audio": true, "transcript": true, "video_moments": true },
  "stale": [],
  "analyses": { "...": "as in .../analysis" },
  "moments": [
    { "t": 0.0, "kind": "beat", "source": "audio", "end": 10.0, "tempo_bpm": 120.0 },
    { "t": 0.0, "kind": "faces", "source": "layout", "count": 1 },
    { "t": 0.0, "kind": "shot", "source": "layout", "detail": "solo_talking_head" },
    { "t": 0.0, "kind": "speaker", "source": "transcript", "speaker": "speaker_0", "first_word": "Here" },
    { "t": 1.0, "kind": "moment", "source": "video", "end": 3.0, "detail": "action_peak", "score": 8, "description": "Rim grab" },
    { "t": 3.0, "kind": "sound", "source": "transcript", "end": 4.0, "detail": "laughter" },
    { "t": 4.0, "kind": "cut", "source": "layout" },
    { "t": 4.0, "kind": "loud", "source": "audio", "end": 4.5, "over_db": 9.0 },
    { "t": 4.0, "kind": "shot", "source": "layout", "detail": "screen_share_with_webcam" }
  ]
}
```

### GET /projects/{id}/analysis

The whole video, on video time. `include` picks sections, comma-separated;
by default all but `words`:

- `metadata` - the upload's metadata (length, resolution, orientation)
- `transcript` - `language`, `provider`, `duration`, `no_audio`, `speakers`
  and `segments` `{start, end, text, speaker}`
- `words` - every transcript word `{t, end, word, speaker, confidence}` (only when named: it is the bulk of a long video)
- `sound_events` - laughter, applause and music, as in the clip analysis
- `video_moments` (+ `video_moments_status`, and `video_moments_pass`
  `{status, error, model, analyzed_at}`) - the vision pass's moments
- `talk_scenes` (+ `talk_scenes_status`, and `talk_scenes_pass`
  `{status, model}`) - the transcript pass's scenes
- `scenes` - every scene `{scene_id, start, end, source, description, hook, topic, clip_type, score, degraded}`
  (`degraded: true`: the AI scene step failed and this is a fixed-length
  fallback scene nobody scored)

`analyses.transcription` reports a re-transcription
(`POST /projects/{id}/transcription`) in the same shape as a clip's runs.
Response:

```json
{
  "project_id": 123,
  "time": { "clock": "video", "unit": "seconds", "note": "Seconds from the start of the upload." },
  "analyses": {
    "transcription": {
      "status": null,
      "...": "same fields as a clip's runs",
      "rest": "POST /api/v1/projects/123/transcription",
      "mcp": "transcribe_video"
    }
  },
  "metadata": { "duration_sec": 693.5, "resolution": "1920x1080", "detected_orientation": "horizontal",
                "...": "the rest of the upload's metadata" },
  "transcript": {
    "language": "eng",
    "provider": "elevenlabs_scribe_v2",
    "duration": 693.5,
    "no_audio": false,
    "speakers": [{ "id": "speaker_0", "seconds": 412.6 }],
    "segments": [{ "start": 99.0, "end": 106.0, "text": "Here we go.", "speaker": "speaker_0" }]
  },
  "sound_events": [{ "t": 103.0, "end": 104.0, "kind": "laughter", "label": "[laughter]", "speaker": null, "confidence": null }],
  "video_moments": [{ "start": 101.0, "end": 103.0, "type": "action_peak", "score": 8, "description": "Rim grab" }],
  "video_moments_status": "ok",
  "video_moments_pass": { "status": "ok", "error": null, "model": "gemini-3.8-flash",
                          "analyzed_at": "2026-10-22T17:40:12+00:00" },
  "talk_scenes": [{ "start": 95.0, "end": 105.0, "clip_type": "story", "score": 7, "hook": "Why he almost quit",
                    "topic": "basketball", "description": "He tells how the season started", "confidence": 0.8 }],
  "talk_scenes_status": "ok",
  "talk_scenes_pass": { "status": "ok", "model": "gpt-4o-mini" },
  "scenes": [{ "scene_id": 2265, "start": 100.0, "end": 110.0, "source": "merged",
               "description": "He grabs the rim and the crowd erupts", "hook": "The dunk nobody saw coming",
               "topic": "basketball", "clip_type": "reaction", "score": 8, "degraded": false }]
}
```

### POST /projects/{id}/clips/{clip_id}/audio-cues

Measure the clip's audio in the background (202): loud moments, quiet
stretches, beat and tempo, singing. Poll `GET .../analysis` until
`analyses.audio_cues.status` is `completed` or `failed`; the result is its
`audio` section. Suggest and Auto-edit measure it themselves, so call this
only to read the audio first. Paid: `ai_suggestions`; no tokens; shares one
rate limit with effects Suggest and Auto-edit. Refusals: `409 clip_file_missing`,
`409 audio_cues_running`, `409 audio_cues_current` (the stored measurement
still matches the clip's file); the demo project is read-only (403). Response:

```json
{
  "project_id": 123,
  "clip_id": 456,
  "status": "running"
}
```

## Editing

Everything the in-app editor does is scriptable.

Two clocks: overlays you **add** (`POST .../overlays`) and every suggestion use
the viewer's time - seconds from the start of the clip as it plays. Saved
settings (`GET | PUT .../settings`) keep times against the padded clip file,
a few seconds longer than the clip at each end; the overlays endpoint converts
for you.

### PUT /projects/{id}/scenes/{scene_id}/boundaries

Trim - move a clip's start/end. All clips on the scene update automatically.
Request body:

```json
{
  "new_start_time": 12.0,
  "new_end_time": 27.5
}
```

Response:

```json
{
  "scene_id": 2265,
  "project_id": 123,
  "old_start_time": 0.0,
  "old_end_time": 42.3,
  "new_start_time": 12.0,
  "new_end_time": 27.5,
  "message": "Scene boundaries updated successfully."
}
```

### GET | PUT /projects/{id}/clips/{clip_id}/settings

Read or update the full editor state: `captions` (style, position, karaoke,
`secondary` for a second caption line), `emojis`, `gifs` (GIFs, stickers and
reaction clips, told apart by `kind`), `sfx`, `zoom`, `screenEffects`,
`framing`, `logo`, `progressBar`, `orientation`, `speed` (0.25-4, the whole
clip) and `speedRanges` (`{start, end, speed}` stretches at their own speed).
**PUT replaces the whole object**: send back everything GET returned, with your
changes — any key you leave out is cleared. To add overlays,
`POST .../overlays` (below) is simpler. Request body:

```json
{
  "orientation": "vertical",
  "captions": {
    "enabled": true,
    "style": "hormozi",
    "karaoke": true
  },
  "speed": 1.25
}
```

Response:

```json
{
  "clip_id": 456,
  "settings": {
    "captions": {
      "enabled": true,
      "style": "hormozi",
      "karaoke": true
    },
    "zoom": null,
    "screenEffects": null,
    "logo": null,
    "progressBar": null,
    "smartCrop": null,
    "gifs": null,
    "orientation": "vertical",
    "speed": 1.25,
    "speedRanges": null
  }
}
```

A zoom is one shape: `{type: "zoom", start, end, scale, glide, target, center}`
— in at `start` (`glide` seconds to get there, 0 snaps), held at `scale`
(1.05-2.5), back at `end` or held to the clip's end when `end` is null. The
older `punch` / `slow` zooms are still accepted and converted.

### GET | PUT /projects/{id}/clips/{clip_id}/captions

Read or replace caption words with word-level timing. Request body:

```json
{
  "words": [
    { "text": "Use", "start": 28.06, "end": 28.14 },
    { "text": "correct", "start": 28.14, "end": 28.22 }
  ]
}
```

### POST /projects/{id}/clips/{clip_id}/transcribe

Transcribe this clip's audio again and make the result its captions. Replaces
any edited words. Answers directly with the new words, in the
`GET .../captions` shape. No tokens; rate-limited.

### PATCH /projects/{id}/clips/{clip_id}/name

Rename a clip. An empty string clears it back to the generated name. Request
body:

```json
{
  "name": "Best hook"
}
```

Response:

```json
{
  "clip_id": 456,
  "display_name": "Best hook"
}
```

### POST /projects/{id}/clips/{clip_id}/overlays

Add overlays at the time the viewer sees. This endpoint makes the conversion to
the saved settings' clock and applies the editor's defaults (size, position,
clamping), and the suggestion endpoints use the same time, so a suggestion can
be sent here as it came. It only adds — to move or remove an overlay, edit the
settings. Send any of the lists:

- `emojis` - floating emojis `{emoji, timestamp, duration?, position?, scale?, spot?}`
- `inline_emojis` - an emoji after one caption word `{emoji, word_index}`
- `gifs`, `stickers` - from GIF search or suggestions `{source_id, slug, mp4_url, width, height, timestamp, duration?, position?, scale?}`;
  a sticker also needs `image_url` and can take `spot`; a GIF can be `full_screen`
- `reaction_clips` - a short movie/TV line with sound: the video pauses at
  `timestamp`, the clip plays full screen, then the video carries on
  `{source_id, slug, mp4_url, timestamp, volume?}`
- `sfx` - `{sfx_id, timestamp, volume?, fade_in_ms?, fade_out_ms?}`, ids from `GET /sfx`
- `zooms` - `{start, end? | length? | hold_to_end?, scale?, glide?, target?, center?}`;
  `target: "face"` zooms on the main face and follows it
- `screen_effects` - `{look, start, length?, strength?}`, `look` one of `bw`,
  `rage`, `rainbow`, `flash`, `strobe`, `shake`, `hype`, `party` (pulses on
  `beat_first` + `beat_period`; on a compilation, `beat_clock: "edit"` for a
  beat of its music, which stays put when the footage under it moves),
  `vignette`
- `speed_ranges` - `{start, length, speed}`, speed 0.25-4

A clip holds up to 5 GIFs, 5 stickers and 5 reaction clips. `spot` makes a
sticker or emoji follow the main face (`mouth`, `eyes`, `cheeks`, `forehead`,
`head`, `above`, `orbit`); it and the face zoom are paid (`smart_crop`) and
need the clip's layout analysis (`layout_summary.can_follow_faces` on the
clip). Request body:

```json
{
  "emojis": [{ "emoji": "🔥", "timestamp": 12.0 }],
  "inline_emojis": [{ "emoji": "💰", "word_index": 5 }],
  "gifs": [{ "source_id": "k1", "slug": "mind-blown", "mp4_url": "https://…", "width": 480, "height": 270, "timestamp": 8.0 }],
  "sfx": [{ "sfx_id": 3, "timestamp": 3.0, "volume": 0.4 }],
  "zooms": [{ "start": 20.0, "length": 1.0, "scale": 1.3 }],
  "screen_effects": [{ "look": "flash", "start": 20.0 }],
  "speed_ranges": [{ "start": 31.0, "length": 2.5, "speed": 2.0 }]
}
```

Returns the clip's settings as saved, like `GET .../settings`.

### GET | POST /projects/{id}/clips/{clip_id}/translations

A second caption line in another language. GET lists the languages this clip
already has (free) and the supported ones. POST `{ "language": "es" }`
translates the clip's captions; it costs 1 token, or nothing when the clip
already has that language. Paid: `caption_translation`. The line shows once
`captions.secondary` is set in the settings (`{ "language": "es" }`).
`GET | POST /projects/{id}/translations` does the same for the whole video
(5 tokens).

### POST /projects/{id}/clips/{clip_id}/layout-analysis

Analyze what each moment of the clip shows: shot types, faces, screen and
webcam panels. Needed before stickers, emojis and zooms can follow a face. Runs
in the background (202); poll `GET /projects/{id}/clips/{clip_id}` for
`features.vision_analysis.layout_rerun_status` (`running` -> `completed` |
`failed`) and read `layout_summary`, or poll `GET .../analysis` for
`analyses.layout.status`: every frame's shot, faces and panels are its
`layout` section. Paid: `smart_crop`; no tokens. 409 while one runs on your
account or when the clip's analysis is already current.

### POST | DELETE /projects/{id}/clips/{clip_id}/logo

Upload (multipart, field `file`) or remove a per-clip logo. Without a browser,
stage it with `POST /uploads/assets` and attach it with
`POST .../logo/from-upload`.

## AI suggestions and Auto-edit

Suggestions are read-only: they return placements on the viewer's time, ready
to send to `POST .../overlays` as they came. Paid: `ai_suggestions`; no tokens;
rate-limited.

### POST /projects/{id}/clips/{clip_id}/{kind}/suggest

`kind` is one of:

- `gifs`, `stickers`, `reaction-clips` - resolved against Klipy
- `emojis` - floating emojis (`min_gap_seconds` query param)
- `sfx` - sound effects from the library
- `effects` - zoom and screen-effect recipes, each expanded into the `zooms`
  and `screen_effects` it is made of
- `speed` - speed ranges: dead air sped up, the biggest silent moment in slow motion

Query param `max_suggestions`. `effects` and `speed` answer with
`audio_cues_ready: false` while the clip's audio is still being measured; ask
again a few seconds later for effects on the beat. A speed suggestion is
`{start, end, speed}`: send it as a speed range with `length` = `end` - `start`.
Response (`gifs`):

```json
{
  "clip_id": 456,
  "suggestions": [
    {
      "source_id": "k1",
      "slug": "mind-blown",
      "title": "Mind Blown",
      "mp4_url": "https://…",
      "width": 480,
      "height": 270,
      "preview_url": "https://…",
      "timestamp": 8.0,
      "duration": 2.0,
      "query": "mind blown",
      "reason": "Reacts to the reveal"
    }
  ]
}
```

### POST /projects/{id}/clips/{clip_id}/auto-edit

Plan a whole edit for the clip — zooms, screen effects, stickers, sounds and
speed ranges — in the background (202). Poll `GET /projects/{id}/clips/{clip_id}`
for `features.vision_analysis.auto_edit_rerun_status` (`running` -> `completed` |
`failed`); the edit is `features.vision_analysis.auto_edit`:
`{effects, stickers, sfx, speed}`, each in its suggestion shape - also the
`auto_edit` section of `GET .../analysis`. Nothing is
applied: send each effect's `zooms` and `screen_effects`, the `stickers`, the
`sfx` and the `speed` ranges to `POST .../overlays`. Paid: `ai_suggestions`;
no tokens; 409 while one runs for the clip.

## Export

### POST /projects/{id}/clips/{clip_id}/export

Render a clip with its saved settings. Charges tokens. Response:

```json
{
  "export_id": 37,
  "status": "rendering",
  "message": "Export render triggered"
}
```

### GET /exports/{export_id}/status

Poll an export. When it completes, `download_url` is a presigned S3 link,
valid until the next 00:00 UTC (at least 30 minutes), that downloads the file.
Response:

```json
{
  "export_id": 37,
  "status": "completed",
  "download_url": "https://clipmaster-media.s3.amazonaws.com/...mp4?...&Expires=...",
  "created_at": "2026-08-14T21:47:35Z",
  "completed_at": "2026-08-14T21:48:42Z"
}
```

### GET /exports

List your exports, newest first. Filter with `clip_id` and/or `project_id`;
page with `limit` and `offset`. **This is how you download a clip:** take the
newest export with `"status": "completed"` and fetch its `download_url`
(a presigned S3 link, valid until the next 00:00 UTC and at least 30
minutes; call again for a fresh one).
Response:

```json
{
  "exports": [
    {
      "export_id": 37,
      "clip_id": 456,
      "project_id": 123,
      "status": "completed",
      "progress": null,
      "export_number": 2,
      "tokens_charged": 1,
      "clip_duration_sec": 42.3,
      "download_url": "https://clipmaster-media.s3.amazonaws.com/...mp4?...&Expires=...",
      "created_at": "2026-08-14T21:47:35Z",
      "completed_at": "2026-08-14T21:48:42Z"
    }
  ],
  "total": 2,
  "limit": 20,
  "offset": 0
}
```

`progress` is 0-1 while an export renders.

## Compilations

A compilation is one edit built from clips of any of your videos. Creating,
arranging and exporting one is paid: `compilation`.

### GET /compilations

List your compilations. `source_project_id` narrows to the ones built from one
upload. Response:

```json
{
  "items": [
    {
      "id": 10,
      "name": "Funny Personal Anecdotes",
      "clip_count": 5,
      "total_duration_sec": 149.9,
      "output": { "width": 1920, "height": 1080, "fps": 30 },
      "format": null,
      "latest_export": null,
      "created_at": "2026-08-10T19:05:41Z"
    }
  ]
}
```

### POST /compilations

Create an empty compilation. Request body:

```json
{
  "name": "Best of March",
  "output": { "width": 1080, "height": 1920, "fps": 30 }
}
```

Returns the compilation, as `GET /compilations/{id}` does.

### GET | PATCH /compilations/{id}

Read one compilation — its clips, `estimated_tokens` for an export, the edit
plan in `arrangement_metadata.edit_plan` and the files it plays in `media` —
or change its `name`, `output`, `logo_config` or `progress_bar_config`.

### GET /compilations/{id}/deletion-preview

What deleting would take with it: `source_project_ids` (uploads that exist
only for this compilation) and `scheduled_posts_pending`.

### DELETE /compilations/{id}

Delete a compilation; its pending posts are cancelled. `delete_sources=true`
also deletes the uploads from the preview; pass them as `expected_project_ids`
and the delete is refused (409) if that set changed since.

### GET /compilations/available-clips

Your clips across all videos, to add. Query params `q`, `limit`, `offset`.

### POST /compilations/{id}/clips

Add a clip: `{ "clip_id": 456, "position": 2 }` (`position` optional).
`DELETE /compilations/{id}/clips/{compilation_clip_id}` removes one.

### POST /compilations/{id}/clips/suggest

More clips from your library that fit this compilation, best first. Add the
ones you want with `POST .../clips`.

### POST /compilations/suggest

Compilation ideas from your library for a brief:
`{ "prompt": "funny moments", "max_suggestions": 3 }`. Each suggestion has a
`theme`, a `reason` and `clip_ids`; turn one into a compilation with
`POST /compilations/from-suggestion` `{ "name": "…", "clip_ids": [456, 457] }`.

### POST /compilations/{id}/export

Render a compilation. Charges tokens for the source seconds it plays
(`estimated_tokens` on the compilation). Response:

```json
{
  "export_id": 5,
  "compilation_id": 10,
  "status": "pending",
  "estimated_tokens": 3,
  "message": "Export started"
}
```

### GET /compilations/{id}/exports/{export_id}/status

Poll a compilation export; `download_url` works as for a clip export.
`GET /compilations/{id}/exports` lists them.

## Compilation editing

A compilation is an edit plan: scenes, each showing one to three panes (a clip
or a file), with transitions and text cards, plus overlays, music and a
voiceover over the whole edit. Times are seconds into the compilation, except
where noted.

### POST /compilations/{id}/arrange

Re-cut the compilation with AI into a new plan of scenes. Replaces the scenes
and the emojis and GIFs pinned to them; captions, music and narration stay.
`format` is one of `custom` (a montage; needs `prompt`), `best_of`,
`countdown`, `trailer`, `hook_loop`, `tutorial`, `qa_highlights`, `auto`.
`prompt` is an optional brief, `effects` turns transitions and effects on or
off. Rate-limited. Request body:

```json
{
  "format": "hook_loop"
}
```

Response:

```json
{
  "id": 10,
  "name": "Funny Personal Anecdotes",
  "format": "hook_loop",
  "total_duration_sec": 58.2,
  "estimated_tokens": 1,
  "clips": [
    {
      "compilation_clip_id": 88,
      "position": 0,
      "clip_id": 456,
      "duration_sec": 12.4,
      "missing": false
    }
  ],
  "arrangement_metadata": {
    "resolved_format": "hook_loop",
    "degraded": false,
    "edit_plan": {
      "version": "2.0",
      "scenes": [
        {
          "id": "1",
          "layout": "full",
          "sources": [
            { "clip_id": 456, "slot": 0, "audio": "primary", "start_offset_sec": 3.0, "end_offset_sec": 9.5, "speed": 1.0 }
          ],
          "text": []
        }
      ]
    }
  }
}
```

`degraded: true` means the AI step failed and the clips were kept in order.

### POST /compilations/{id}/plan/undo · /plan/reset · /plan/place-added-clips

- `plan/undo` - back to before the last arrange, reset or place; undoing again redoes
- `plan/reset` - a plain cut: every clip whole, one after another
- `plan/place-added-clips` - work clips added since the AI edit into it, leaving the rest as it is

No body.

### POST /compilations/{id}/scenes/{scene_id}/regenerate

Re-cut one scene with AI, leaving the rest. Optional
`{ "prompt": "use the boat clip instead" }`. Scene ids are text. A re-cut
scene loses its beat pattern; a pattern's part can't be re-cut (422).

### PUT /compilations/{id}/scenes/{scene_id}/beat-pattern

Give one scene a beat pattern: short muted repeats of its clip, each ending
on the music's beat, played right before it. `{ "pattern": "stutter" }`, or
`null` to take it out; another pattern on the scene is replaced. Patterns:
`peak_stutter` (a beat from the scene's first peak, twice; refused when the
scene plays no peak), `stutter` (the opening beat twice), `quick_stutter` (the opening half-beat
four times), `build_up` (2, 1, ½ and ½ beat of the opening), `jump_cut` (a beat
each from 0 %, 40 % and 75 % in), `teaser` (a beat each of three of its
moments). The repeats are real scenes in the plan (`beat_part_of`: the
scene's id; the scene gets `beat_pattern`), arrive on hard cuts and are not
billed. What follows moves with its footage. Needs measured music and no
voiceover, and a scene of one clip at normal speed, with no sound effects of
its own, arriving on a hard cut. 409 `music_not_measured` while the music's
beat is measured (try again in a minute); 422 `no_music`, `narrated`,
`beat_part`, `unknown_beat_pattern` or `beat_pattern_not_allowed` (with a
`reason`: `not_one_clip`, `speed`, `clip_sounds`, `transition`,
`does_not_fit`, `no_beat` — the music has no beat where the scene starts).
Returns the compilation.

### PUT /compilations/{id}/plan

Replace the edit plan. Each scene has an `id`, a `layout` (`full` 1 pane,
`stack2` / `cols2` / `pip` 2, `stack3` / `cols3` 3), its `sources`, an optional
`transition_in` (`cut`, `fade`, `slide`, `wipe`, up to 1000 ms) and `text`
cards (`title` or `label`, timed in ms from the scene's start). A source plays
a `clip_id` or a `media_key`; `start_offset_sec` / `end_offset_sec` are the
window's start and end in seconds into the clip's scene; at most one source per
scene has `audio: "primary"`; `speed` and `speed_ranges` work as on a clip.
A split scene lasts as long as its longest source; a source that ends earlier
shrinks away while the others grow into its space, over the scene's optional
`pane_exit_ms` (0-1000, 0 is instant, default 400). A clip comes in clean: its
own GIFs, emojis, sounds, zooms and screen effects play only on a source with
`clip_effects: true` (default false); send each source back with the value it
came with. Overlays left out are kept; `music` left out is removed. Request body:

```json
{
  "version": "2.0",
  "scenes": [
    {
      "id": "1",
      "layout": "full",
      "sources": [{ "clip_id": 456, "audio": "primary", "start_offset_sec": 2.0, "end_offset_sec": 14.5 }],
      "text": [{ "text": "Day one", "role": "title", "start_ms": 0, "duration_ms": 2000 }]
    },
    {
      "id": "2",
      "layout": "stack2",
      "transition_in": { "kind": "fade", "duration_ms": 400 },
      "sources": [
        { "clip_id": 457, "slot": 0, "audio": "primary", "speed": 1.5 },
        { "clip_id": 458, "slot": 1 }
      ]
    }
  ],
  "music": { "track_id": "a1b2c3", "volume": 0.3 }
}
```

### POST | PATCH /compilations/{id}/plan/overlays

POST adds overlays over the whole edit, with the clip overlays' shapes:
`emojis`, `gifs`, `stickers`, `zooms`, `screen_effects`, `sfx` (no `spot`, no
face zoom, no reaction clips — use a media scene). Compilation suggestions can
be sent as they came. PATCH replaces a whole stored set instead: `captions`,
`emojis`, `gifs`, `screenEffects`, `zoom`, `sfx` — only what you send changes,
`[]` clears it. Request body (POST):

```json
{
  "emojis": [{ "emoji": "🎉", "timestamp": 3.0 }],
  "screen_effects": [{ "look": "bw", "start": 10.0, "length": 2.0 }]
}
```

Returns the compilation.

### POST /compilations/{id}/auto-edit

Edit the whole compilation in one go and apply it, filling only what the
edit leaves empty. One plan covers all of it: how each scene without a
transition arrives (any available transition, never beside a reaction clip),
text cards on scenes without text (words, font, size, weight, position and
timing), zooms, screen effects, stickers, GIFs, emojis, sound effects, up to
2 reaction-clip scenes and a library track when there is no music. It also
puts speed ranges on clip panes at normal speed with none (dead air sped up,
the big silent moment slowed, inside the pane's window) and switches the
progress bar on when it was never set. A transition, a speed range or an
inserted scene moves what follows with its footage. Effects land on the
edit's measured audio: party lights on its music's beat (or an audible
clip's), hits on loud moments. When the edit has no music, the track is picked
first, so its beat is used in the same run; every audio is measured once, by
file, and one not measured yet gets its beat on the next run. With measured
music and no voiceover it also moves hard cuts onto the beat (by up to half a
beat and 0.35 s, never across a spoken word or an overlay, never leaving a
scene under 1 s) and may give one scene a beat pattern (see
`PUT .../beat-pattern`).

Body (all optional): `direction`, up to 300 characters of what you want
from it ("hype energy, big bold text, lots of GIFs"); the model's plan follows
it (speed ranges and the progress bar don't).
It is kept on the compilation (`arrangement_metadata.auto_edit_direction`)
and reused when left out; `""` clears it. `replace_take` swaps a take for a
new one (shuffle).

Returns the compilation, the `take` (what it added, `null` when nothing) and
counts. `POST /compilations/{id}/auto-edit/undo` with the `take` as the body
takes exactly that out (a transition, text card, track, speed range, cut on
the beat, beat pattern or progress bar changed since stays). A beat
pattern's repeats are in `scene_ids`. 409 `edit_changed` if the scenes changed
while it ran. Paid: `compilation`; no tokens; rate-limited. Response:

```json
{
  "compilation": { "id": 10, "...": "as GET /compilations/{id}" },
  "take": {
    "scene_ids": ["r1"],
    "effect_ids": ["e1", "e2"],
    "sticker_ids": ["g1"],
    "emoji_ids": ["m1"],
    "zoom_ids": ["z1"],
    "sfx_ids": ["s1"],
    "transitions": [{ "scene_id": "2", "kind": "slide", "duration_ms": 400 }],
    "text_cards": [{ "scene_id": "1", "text": "Best of the year" }],
    "music_track_id": "lib-42",
    "speed_ranges": [{ "scene_id": "2", "slot": 0, "start": 24.0, "end": 25.2, "speed": 0.5 }],
    "beat_trims": [{ "scene_id": "1", "slot": 0, "end_before": 6.0, "end_after": 5.8 }],
    "beat_patterns": [{ "scene_id": "3", "pattern": "stutter" }],
    "progress_bar": true
  },
  "counts": { "effects": 2, "stickers": 0, "gifs": 1, "emojis": 1, "reaction_clips": 1, "zooms": 1,
              "sounds": 1, "transitions": 1, "texts": 1, "music": 1, "speed_ranges": 1,
              "beat_cuts": 1, "beat_patterns": 1, "progress_bar": 1 }
}
```

### GET /compilations/{id}/audio

What the edit's audio does, on its own clock (seconds from its start — the
clock `POST .../overlays` takes): the music's beat spans (`first_beat`,
`period`, `tempo_bpm`, `clock: "edit"`), or an audible clip's or file's when
there is no music; loud moments (`t`, `over_db`), quiet stretches and singing.
`?onsets=true` adds every onset as `[t, strength]` (large). This is what
Auto-edit places party lights and hits on; to place party lights yourself,
pass a beat's `first_beat`, `period` and `clock` as the screen effect's
`beat_first`, `beat_period` and `beat_clock`.

Every audio (the music, each clip, each uploaded file or reaction clip) is
measured once, in the background, the first time it is read or joins the
edit. `sources` lists each with its `status` (`measured`, `measuring`,
`failed`, `not_measured`); `measuring` is true until all are measured — read
again in a moment. Paid: `compilation`; no tokens.

```json
{
  "compilation_id": 10,
  "duration_sec": 37.4,
  "measuring": false,
  "sources": [
    { "kind": "music", "status": "measured", "track_id": "lib-42" },
    { "kind": "clip", "status": "measured", "scene_id": "1", "clip_id": 1621 }
  ],
  "beat": [{ "start": 12.0, "end": 37.4, "first_beat": 12.02, "period": 0.38,
             "tempo_bpm": 157.9, "clock": "edit" }],
  "loud": [{ "t": 33.0, "end": 33.4, "over_db": 11.0 }],
  "quiet": [{ "start": 3.4, "end": 9.0, "level_db": -38.0 }],
  "singing": [],
  "onsets": null
}
```

### POST /compilations/{id}/media-scenes

Add a scene that plays a file instead of a clip: a Klipy reaction clip
(`klipy`, from `GET /gifs/search?kind=clip`) or a video or image staged with
`POST /uploads/assets` (`upload_key`). It goes after `after_scene_id`, or
last; with `into_scene_id` it becomes one more pane beside that scene's clips.
Later overlays move with their footage. Uploaded video counts toward the
export's tokens like footage; reaction clips and images are free. Paid:
`compilation`. Request body:

```json
{
  "klipy": { "source_id": "c3", "slug": "emotional-damage", "title": "Emotional Damage", "mp4_url": "https://…" },
  "after_scene_id": "2"
}
```

### POST | PATCH | DELETE /compilations/{id}/narration

A voiceover over the whole edit, transcribed for captions. POST uploads it
(multipart, field `file`), or attach a staged one with
`POST .../narration/from-upload`. PATCH sets `{ "volume": 0.9, "duck": true }`;
DELETE removes it.

### Music

- `POST /compilations/{id}/music/suggest` - tracks that fit the edit, with a reason each
- `GET /music/search?q=` - search the library (`genre`, `duration_min`, `duration_max`)
- `POST /music/tracks/{track_id}/select` - take a track into your library; then set
  `music: { "track_id": "…" }` in the plan

### Suggestions for the edit

Read-only, on the compilation's own seconds. Paid: `compilation`.

- `POST /compilations/{id}/gifs/suggest`, `/stickers/suggest`, `/emojis/suggest` - send them to `POST .../plan/overlays`
- `POST /compilations/{id}/scenes/{scene_id}/text/suggest` - title and label lines for one scene

## Scheduling

Needs the social-scheduling entitlement (`social_scheduling`) and a channel
connected in the web app.

### GET /social/accounts

Your connected channels. Response:

```json
{
  "platforms_available": ["youtube"],
  "accounts": [
    {
      "id": 2,
      "platform": "youtube",
      "account_name": "MyChannel",
      "status": "active"
    }
  ]
}
```

### DELETE /social/accounts/{id}

Disconnect a channel. Its post history, pending and published, goes with it.

### POST /scheduled-posts

Schedule a clip or compilation export to publish, at least 10 minutes ahead.
Send `clip_id` + `export_id`, or `compilation_id` + `compilation_export_id`.
`privacy` is `public` (the default), `unlisted` or `private`. Idempotent: a
retry with the same account, export, and time returns the existing post rather
than publishing twice. Request body:

```json
{
  "social_account_id": 2,
  "clip_id": 456,
  "export_id": 37,
  "scheduled_at": "2026-08-20T16:00:00Z",
  "title": "The pivot that saved us",
  "hashtags": ["startup", "founder"],
  "privacy": "public"
}
```

Response:

```json
{
  "id": 12,
  "clip_id": 456,
  "export_id": 37,
  "social_account_id": 2,
  "platform": "youtube",
  "account_name": "MyChannel",
  "status": "scheduled",
  "scheduled_at": "2026-08-20T16:00:00",
  "title": "The pivot that saved us"
}
```

### GET /scheduled-posts?from_date=&to_date=

Calendar range of scheduled and published posts — ISO-8601 dates, 45 days
either side of today when left out. Response:

```json
{
  "posts": [
    {
      "id": 12,
      "status": "scheduled",
      "scheduled_at": "2026-08-20T16:00:00",
      "title": "The pivot that saved us"
    }
  ]
}
```

### PATCH | DELETE /scheduled-posts/{id}

Retime or edit a post before it publishes (`scheduled_at`, `title`,
`description`, `hashtags`, `privacy`), or cancel it.

### POST /scheduled-posts/metadata-suggest

AI title, description and hashtags from a clip's transcript:
`{ "clip_id": 456, "platform": "youtube" }`. Response:

```json
{
  "title": "The pivot that saved us",
  "description": "How one decision turned the company around.",
  "hashtags": ["startup", "founder"]
}
```

## Library

### GET /export/styles

List the 15 caption styles and their defaults. `GET /export/styles/{key}`
returns one. Response:

```json
[
  {
    "key": "clean",
    "name": "Clean",
    "category": "social",
    "fontFamily": "Inter",
    "fontSize": 56,
    "color": "#FFFFFF",
    "highlightColor": "#9DC34F"
  }
]
```

### GET /sfx

The sound-effect library; its ids go in `sfx` overlays. Optional `category`.

### GET /gifs/search?q= · GET /gifs/trending

Search Klipy, or list what's trending. `kind` is `gif` (default), `sticker` or
`clip` (reaction clips); page with `page` and `per_page`. Place a result with
`id` as `source_id`, `mp4.url` as `mp4_url`, `mp4.width` / `mp4.height`, and
for a sticker `image.url` as `image_url`.

### GET /music/search · POST /music/tracks/{track_id}/select

Background music for compilations; see Compilation editing.

## Account

### GET /billing/balance

Your tokens. Response:

```json
{
  "balance": 120,
  "reserved": 3,
  "available": 117,
  "total_purchased": 200,
  "total_spent": 80
}
```

### GET /billing/usage?period=30

What you used over the last `period` days (1-365): `tokens_spent`,
`tokens_purchased`, `uploads_count`, `exports_count`, `minutes_processed`,
`minutes_exported`.

## Errors

Structured errors carry a machine-readable code:

```json
{
  "detail": {
    "error": "insufficient_tokens",
    "message": "Not enough tokens for this operation"
  }
}
```

Common codes: `api_access_required` / `session_auth_required` (403),
`subscription_required` (403, names the plan `feature`),
`insufficient_tokens` (402), `export_in_progress` / `export_expired` (409). A
bad or revoked key returns 401; over a rate limit returns 429 with
`Retry-After`.

## Token cost

Metered exactly like the app - 1 token = 1 source minute for processing an
upload or a YouTube import; exports charge per the app's rules (a compilation
by the source seconds it plays); a clip caption translation costs 1 token.
Suggestions, Auto-edit, arranging, layout analysis, audio measurement and
re-transcribing spend no tokens; reading the analysis is free. There is no separate API billing.

## MCP server

An official MCP server wraps this API as tools for Claude Desktop, Claude Code,
and any MCP client. Setup guide: https://clipmaster.app/docs/mcp
