---
name: dayvid-public-api
description: Generate music videos with subtitles, AI imagery, and outros from an audio file using the Dayvid Public API v1. Covers Suno/Udio link ingest, custom audio + image upload (including custom YouTube thumbnails and your own static background image), the full Express submit pipeline, job polling, cancel, publishing, and download. Use this skill when the user asks for help creating a video on Dayvid via API, or wants to integrate Dayvid into their own pipeline.
metadata:
  version: 1.0.0
---

# Dayvid Public API v1

## 1. What You Can Do with the Dayvid API

Dayvid (https://dayvid.ai) is a music-to-video creation platform, and this API lets you drive it end to end. Give it an audio track (a Suno song, a Udio track, a SoundCloud track, or any MP3/WAV you own) and you can:

- **Create a full music video**: one POST returns a rendered MP4 with auto-transcribed, time-aligned subtitles in a chosen style, AI-generated scene imagery driven by the lyrics (or a curated video-loop / your own static background), vertical (Reels/Shorts/TikTok) or horizontal (YouTube) framing, and an optional outro (section 4)
- **Cut one song into several short highlight clips**, each a standalone ready-to-post MP4 (section 11)
- **Manage your projects**: list, search, and inspect them, including render state and download URLs (section 10)
- **Publish the result to social platforms** like YouTube in one call, custom thumbnail included (section 12)

The API exposes the same "Express" one-shot pipeline that powers the web app. One POST submits a job; you receive an email when the render finishes and can fetch the signed download URL via a single status request.

## 2. Account + API Token Setup

You need two things before any curl call works: an account and a personal API token.

### 2.1. Create the account

1. Open https://dayvid.ai
2. Click **Sign in** (top right)
3. Choose **Continue with Google** and complete the OAuth consent screen
4. You land on the dashboard

### 2.2. Subscribe to an API-capable plan

The Free and Lite tiers do NOT include API access. See section 3 for the rule. Pick Start, Pro, or King from the pricing page: https://dayvid.ai/pricing

### 2.3. Mint a token

1. Go to **Profile**: https://dayvid.ai/profile
2. Scroll to the **API tokens** card
3. Click **Create token**, give it a label (e.g. `cli-laptop`), confirm
4. Pick an expiration. The dialog defaults to **90 days**; choose "Never" explicitly if you want a token that does not expire on its own (full lifecycle rules in section 17).
5. Copy the `dvy_...` value SHOWN ONCE. Store it in a secret manager. We never display it again.
6. Export it for the rest of this guide:

```bash
export DAYVID_TOKEN="dvy_xxxxxxxxxxxxxxxxxxxxxxxx"
export DAYVID_BASE="https://dayvid.ai"
```

All requests below send `Authorization: Bearer $DAYVID_TOKEN`.

## 3. Plans That Can Use the API + The `/me` Health Check

API access is part of paid Dayvid plans. Free and Lite subscribers can mint a token but every endpoint except `/me` will reject the request with a 402 telling them to upgrade.

| Plan   | Can call the API | Monthly credits |
|--------|------------------|-----------------|
| Free   | No               | (trial only)    |
| Lite   | No               | 700             |
| Start  | Yes              | 1,500           |
| Pro    | Yes              | 4,000           |
| King   | Yes              | 10,000          |

Upgrade at https://dayvid.ai/pricing.

`GET /api/v1/me` is the recommended health check. It returns your current plan, your remaining credits, and confirms that your token is valid. It is open to every authenticated caller (including Free/Lite) so you can detect an upgrade requirement before submitting.

```bash
curl -s "$DAYVID_BASE/api/v1/me" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Response:

```json
{
  "userId": "uuid",
  "token": { "id": "uuid", "name": "cli-laptop" },
  "subscription": {
    "status": "active",
    "plan": { "slug": "start", "name": "Start", "apiAccess": true },
    "currentPeriodEnd": "2026-06-22T00:00:00.000Z"
  },
  "credits": 1483
}
```

The boolean inside `subscription.plan` tells you whether the current plan can call the API. Always confirm it is `true` and that `credits` is at least the cost of one run (a typical Express job reserves around 100 credits) before submitting.

Budgeting a run: there is no dry-run estimate endpoint today. The exact reservation for your submitted options comes back in the submit response as `estimateSnapshot` (see 4.5), and the job is rejected with `402 insufficient_credits` (including `needed` and `balance`) before anything is charged if you cannot afford it. As a rule of thumb, cost grows with track length, with `visualStyle: "moving"` (and higher `animationStop` tiers), and with `resolutionPreset: "2K"`; `videoLoop` and `static` + your own image are the cheapest paths because nothing is AI-generated.

## 4. The Express Submit Flow

Producing a video is two phases:

1. **Ingest audio** so the server has a stable storage path it can read
2. **Submit settings** to `POST /api/v1/express`. You receive a `jobId` immediately; the render completes in 10-40 minutes and we notify you by email.

Two ways to ingest audio:

- Paste a public link from Suno, Udio, or SoundCloud as `audioUrl`
- Upload your own audio via a signed URL, then pass the resulting `storagePath`

`/api/v1/express` accepts exactly one of `audioStoragePath` or `audioUrl`.

### 4.1. Path A: Suno link

Paste the share or song URL from Suno. Both shapes work:

- `https://suno.com/s/<short>` (the "Copy link" share URL)
- `https://suno.com/song/<uuid>` (the canonical song page)

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "projectName": "Suno demo",
      "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
      "aspectRatio": "9:16",
      "brandId": null,
      "assetStrategy": "regenerate",
      "visualStyle": "moving",
      "animationStop": 2,
      "subtitlePresetName": "Punchy",
      "generationPresetId": "default",
      "delivery": "auto-render"
    }
  }' | jq
```

### 4.2. Path B: Udio link

Paste the song URL from Udio: `https://www.udio.com/songs/<id>`.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://www.udio.com/songs/uXYZabcDEF",
      "aspectRatio": "16:9",
      "brandId": null,
      "assetStrategy": "regenerate",
      "visualStyle": "moving",
      "animationStop": 2,
      "subtitlePresetName": "Glow",
      "generationPresetId": "ghibli",
      "delivery": "auto-render"
    }
  }' | jq
```

### 4.3. Path C: SoundCloud link

Paste the track URL: `https://soundcloud.com/<artist>/<track>`.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://soundcloud.com/artist-name/track-name",
      "aspectRatio": "9:16",
      "brandId": null,
      "assetStrategy": "regenerate",
      "visualStyle": "moving",
      "animationStop": 2,
      "subtitlePresetName": "Punchy",
      "delivery": "auto-render"
    }
  }' | jq
```

### 4.4. Path D: Custom audio upload (MP3, WAV, FLAC, M4A, etc.)

`POST /api/v1/audio-uploads` returns a one-time signed PUT URL for Supabase Storage. The bytes never touch the Dayvid API handler. The request body needs only `fileName` (with an extension). Accepted types: `audio/mpeg`, `audio/mp3`, `audio/wav`, `audio/x-wav`, `audio/mp4`, `audio/aac`, `audio/flac`, `audio/ogg`. Maximum size: 100 MB.

Step 1: request the signed URL.

```bash
FILE="/path/to/song.mp3"

curl -s -X POST "$DAYVID_BASE/api/v1/audio-uploads" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "fileName": "song.mp3" }' | tee /tmp/upload.json | jq

SIGNED_URL=$(jq -r .signedUploadUrl /tmp/upload.json)
STORAGE_PATH=$(jq -r .storagePath /tmp/upload.json)
```

Step 2: PUT the bytes to the signed URL.

```bash
curl -s -X PUT "$SIGNED_URL" \
  -H "Content-Type: audio/mpeg" \
  --data-binary "@$FILE"
```

Step 3: submit using `audioStoragePath`.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"settings\": {
      \"audioStoragePath\": \"$STORAGE_PATH\",
      \"aspectRatio\": \"9:16\",
      \"brandId\": null,
      \"assetStrategy\": \"regenerate\",
      \"visualStyle\": \"moving\",
      \"animationStop\": 2,
      \"subtitlePresetName\": \"Punchy\",
      \"generationPresetId\": \"default\",
      \"delivery\": \"auto-render\"
    }
  }" | jq
```

### 4.5. Submit response

All paths return the same 202 payload:

```json
{
  "projectId": "uuid",
  "jobId": "uuid",
  "estimateSnapshot": {
    "total": 95,
    "reservedAtSubmit": 100,
    "marginCredits": 5,
    "breakdown": [...]
  },
  "pollUrl": "/api/v1/jobs/uuid"
}
```

`reservedAtSubmit` is how many credits we lock the moment the job is enqueued. Any unused credits are refunded when the job completes.

### 4.6. Custom static background image (bring your own still)

By default a `static` visual style generates the background image with AI. To use your OWN image as the static background instead, upload it first and reference its storage path on submit. Two steps, mirroring the audio upload (4.4) and the YouTube thumbnail (12.4) flows — they all use the same general-purpose image endpoint.

Step 1: upload the image. `POST /api/v1/image-uploads` returns a one-time signed PUT URL. The request body needs only `fileName` (with an extension); the bytes never touch the API handler.

```bash
IMG="/path/to/background.png"

curl -s -X POST "$DAYVID_BASE/api/v1/image-uploads" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "fileName": "background.png" }' | tee /tmp/img.json | jq

IMG_SIGNED_URL=$(jq -r .signedUploadUrl /tmp/img.json)
BG_PATH=$(jq -r .storagePath /tmp/img.json)

curl -s -X PUT "$IMG_SIGNED_URL" \
  -H "Content-Type: image/png" \
  --data-binary "@$IMG"
```

Step 2: submit with `visualStyle: "static"`, `staticImageSource: "upload"`, and `backgroundImageStoragePath`.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"settings\": {
      \"audioStoragePath\": \"$STORAGE_PATH\",
      \"aspectRatio\": \"9:16\",
      \"brandId\": null,
      \"visualStyle\": \"static\",
      \"staticImageSource\": \"upload\",
      \"backgroundImageStoragePath\": \"$BG_PATH\",
      \"subtitlePresetName\": \"Punchy\",
      \"delivery\": \"auto-render\"
    }
  }" | jq
```

`generationPresetId` is not needed here: in upload mode no image is generated, so the preset is ignored. Because the AI background generation is skipped, this run reserves fewer credits than a generated static run (only transcription carries cost) — you will see a smaller `estimateSnapshot`.

Background image requirements (distinct from the YouTube thumbnail caps in 12.4):

- It must be an image you uploaded — referencing a path that is not yours returns `400 invalid_settings` (`background path is not owned by the requester`).
- It must already be uploaded — if the PUT has not landed yet, `400 invalid_settings` (`background image not found`).
- It must be `≤ 25 MB` and one of `image/jpeg`, `image/png`, `image/webp` (webp IS accepted here, unlike YouTube thumbnails), else `400 invalid_settings` (`background image too large` / `background image type not supported`).

Combination errors (rejected before any work): `staticImageSource: "upload"` without `backgroundImageStoragePath` → `400 static_image_source_path_missing`; a `backgroundImageStoragePath` without `staticImageSource: "upload"` → `400 background_path_requires_upload_source`; either field on a non-`static` visualStyle → `400 static_image_source_requires_static_style`.

## 5. Settings Reference

Every Express submit accepts the fields below. They mirror the controls in the Dayvid web app's Express form, so anything you can do there you can do here.

### 5.1. `projectName` (optional, string)

Display name for the project. If omitted, the server uses `Untitled Express` and the auto-namer rewrites it from the transcript after first segment processes.

### 5.2. `audioStoragePath` XOR `audioUrl` (required, one of)

See section 4. Exactly one must be present.

### 5.3. `outputLanguage` (optional, string)

BCP-47 code like `en` or `pt-BR`. When omitted, transcription auto-detects.

### 5.4. `aspectRatio` (required, `"9:16" | "16:9"`)

- `9:16`: vertical, for Reels / Shorts / TikTok
- `16:9`: horizontal, for YouTube and landscape players

Optional when you supply `presetId` (see 5.16): the preset's saved aspect ratio is used as the fallback. If the preset has no aspect ratio AND you omit it on the request, you get `400 aspect_ratio_required`.

### 5.5. `brandId` (required, `string | null`)

UUID of one of your brands. When set, the brand's visual-style text and reference images steer scene generation, and brand fonts become available for subtitles (see section 9 for the full setup). Use `null` to skip brand injection. Get the UUID from your brand's page at https://dayvid.ai/brands, or programmatically via `GET /api/v1/brands` (see 8).

### 5.6. `assetStrategy` (optional, `"reuse" | "regenerate"`, default `"regenerate"`)

Only relevant when `brandId` is set. Without a brand, the field is ignored and treated as `regenerate`.

- `regenerate`: always synthesize fresh AI images for every scene. Costs more credits.
- `reuse`: when scenes resemble images already in your brand catalog, recycle them. Cheaper, faster.

### 5.7. `visualStyle` (required, `"moving" | "static" | "videoLoop"`)

- `moving`: scene images are animated (Ken Burns / parallax). Most cinematic.
- `static`: a single still image is the whole background. By default it is AI-generated; set `staticImageSource` (5.7a) to supply your own image instead. Lowest credit cost.
- `videoLoop`: ignore generated imagery; use a curated MP4 loop background. Requires `videoLoopPresetSlug`.

### 5.7a. `staticImageSource` (optional, `"generated" | "upload"`, default `"generated"`)

Only meaningful when `visualStyle === "static"`. `"generated"` (the default when omitted) brainstorms and renders an AI cover from the lyrics. `"upload"` uses your own image, supplied via `backgroundImageStoragePath` (5.7b), and skips AI background generation entirely (cheaper). Sending this field on a non-`static` visualStyle returns `400 static_image_source_requires_static_style`.

### 5.7b. `backgroundImageStoragePath` (required when `staticImageSource === "upload"`)

Storage path of an image you uploaded via `POST /api/v1/image-uploads` (see 4.6 for the full flow). Must be owned by you (`{userId}/` prefix), already uploaded, `≤ 25 MB`, and one of `image/jpeg`, `image/png`, `image/webp`. Ignored unless `staticImageSource === "upload"`; sending it without that source returns `400 background_path_requires_upload_source`, and sending `"upload"` without this path returns `400 static_image_source_path_missing`. See 4.6 for all error codes and requirements.

### 5.8. `videoLoopPresetSlug` (required when `visualStyle === "videoLoop"`)

Curated background catalog:

| Slug          | Vibe                                    |
|---------------|-----------------------------------------|
| `train`       | First-person train ride                 |
| `snow-pine`   | Snowy pine forest                       |
| `three-moons` | Cosmic three-moon sky                   |
| `nyc-taxi`    | New York taxi window POV                |
| `submersible` | Deep-sea submersible window             |
| `dancefloor`  | Neon dancefloor                         |

### 5.9. `animationStop` (required only when `visualStyle === "moving"`, `0 | 1 | 2 | 3 | 4`)

Animation tier for the moving pipeline: how many scene shots get animated into video clips. `0` = no animation, higher tiers animate progressively more shots (and cost more credits). Required for `"moving"` (omitting it, or sending `null`, returns 400 `invalid_settings` with `path: "settings.animationStop"`). The `"static"` and `"videoLoop"` styles skip the animate stage entirely, so the field is ignored there: omit it or send `null`.

### 5.10. `subtitlePresetName` (required when `subtitleStyleOverride` is omitted)

Case-sensitive. One of: `Basic`, `Shadow`, `Glow`, `White`, `Base Blue`, `Gold`, `Elegant`, `Opacity`, `Playful`, `Punchy`, `Focus`, `Spotlight`.

**Mutex with `subtitleStyleOverride`** (see 5.13): send `subtitlePresetName` OR `subtitleStyleOverride`, never both. Sending both returns `400 subtitle_source_conflict`. Sending neither returns `400 subtitle_source_missing`.

The "neither" rule relaxes when you supply `presetId` (see 5.16): the preset's saved subtitle style fills the slot, so you can omit both. If you do send one of them with `presetId`, your explicit choice wins over the preset's style. Sending both is still rejected with `400 subtitle_source_conflict`.

### 5.11. `generationPresetId` (optional, string, default `"default"`)

Visual style for AI scene generation. One of: `default`, `ghibli`, `pixar`, `anime`, `sketch-color`, `sketch-bw`, `lego`, `sci-fi`, `retro-cartoon`, `pixel-art`, `anime-realism`, `fantasy`, `movie`, `kids-book`, `minecraft`, `new-yorker-cartoon`, `1950s-ad`, `renaissance-fresco`, `modern-noir`, `expressive-ink`, `cosmic-baroque`, `epic-lineburst`, `claymation`, `photography`, `illustration`.

Ignored (and not validated) when no AI image is generated: `visualStyle === "videoLoop"`, or `visualStyle === "static"` with `staticImageSource === "upload"`. Omit it in those cases.

### 5.12. `visualGuidelines` (optional, string, max 3000 chars)

Freeform instructions injected into the image prompt: characters, palette hints, locations. Example: `"Anime protagonist with red hoodie, tokyo neon streets, rainy"`.

### 5.13. `subtitleStyleOverride` (optional, object | null)

Full custom subtitle style. **Replaces** the preset entirely — there is no field-level merge. When you supply this object, omit `subtitlePresetName` (see 5.10 mutex rule). The renderer reads every field of this object verbatim; missing optional fields stay missing in the rendered output (no silent inheritance from any preset).

Prototype it visually: the [subtitle style playground](https://dayvid.ai/docs/api/subtitle-preview) edits this exact JSON shape with a live caption preview and the same validation this endpoint applies, then gives you a copy-ready snippet.

#### Required fields (11)

| Field            | Type        | Range / Enum                                                          |
|------------------|-------------|-----------------------------------------------------------------------|
| `fontFamily`     | string      | One of the names from the font catalog below. Case-sensitive.         |
| `fontSize`       | number      | 8 - 200                                                                |
| `textColor`      | string      | CSS color, ≤ 64 chars                                                  |
| `outlineColor`   | string      | CSS color, ≤ 64 chars                                                  |
| `outlineWidth`   | number      | 0 - 40                                                                 |
| `shadowColor`    | string      | CSS color, ≤ 64 chars                                                  |
| `shadowDistance` | number      | 0 - 40                                                                 |
| `shadowBlur`     | number      | 0 - 80                                                                 |
| `animationIn`    | object      | `{ type, durationMs }` — see ElementAnimation below                    |
| `animationOut`   | object      | `{ type, durationMs }` — see ElementAnimation below                    |
| `vttDisplayMode` | enum        | `"highlight"` \| `"word"` \| `"reveal"`                               |

#### Optional fields (16)

| Field                       | Type    | Range / Enum                                       |
|-----------------------------|---------|----------------------------------------------------|
| `backgroundColor`           | string  | CSS color, ≤ 64 chars                              |
| `textTransform`             | enum    | `"none"` \| `"uppercase"` \| `"lowercase"`         |
| `fontWeight`                | number  | 100 - 900                                          |
| `x`                         | number  | 0 - 100 (% horizontal position)                    |
| `y`                         | number  | 0 - 100 (% vertical position)                      |
| `maxWidth`                  | number  | 10 - 100                                           |
| `highlightColor`            | string  | CSS color, ≤ 64 chars                              |
| `highlightBackgroundColor`  | string  | CSS color, ≤ 64 chars                              |
| `highlightFontSize`         | number  | 8 - 200                                            |
| `highlightFontWeight`       | number  | 100 - 900                                          |
| `opacity`                   | number  | 0 - 100                                            |
| `highlightOpacity`          | number  | 0 - 100                                            |
| `glow`                      | number  | 0 - 100                                            |
| `highlightGlow`             | number  | 0 - 100                                            |
| `unrevealedOpacity`         | number  | 0 - 100                                            |
| `wordsPerPage`              | number  | 1 - 20                                             |

#### `ElementAnimation` shape

```json
{ "type": "fade", "durationMs": 200 }
```

`type` is one of: `fade`, `slide`, `scale`, `none`, `vapor`, `blur`, `bounce`, `flip`, `zoom`, `wipe`, `spin`, `reveal`, `dissolve`, `stagger`. `durationMs` is 0 - 10000. Optional `direction` (when applicable): `"up" | "down" | "left" | "right"`.

#### Font catalog (`fontFamily` values)

`fontFamily` is validated against three sets. Names not in any set return `422 font_not_available` with the full list of valid names in the response body. **Case-sensitive.**

**Built-in Google Fonts** (13, always available):
`Lexend`, `Roboto`, `Poppins`, `Montserrat`, `Open Sans`, `Oswald`, `Bangers`, `Anton`, `DynaPuff`, `Bebas Neue`, `Libre Baskerville`, `Roboto Slab`, `Inter`.

**System fonts** (9, available on the renderer):
`Arial`, `Georgia`, `Impact`, `Courier New`, `Verdana`, `Times New Roman`, `Comic Sans MS`, `Trebuchet MS`, `Palatino`.

**Brand custom fonts**: when you submit a `brandId` you own, any fonts uploaded to that brand from the web UI (`https://dayvid.ai/brands/<brandId>`, Fonts section) are also accepted. The `422 font_not_available` response lists them under `availableBrand` so you can discover what is connected.

#### Example: Punchy as a starting point, with cyan text and a thicker outline

Note that `subtitlePresetName` is omitted (mutex). The override below is the canonical Punchy style (`src/components/flows/shared/subtitles/state/subtitlePresets.ts`, as of 2026-05) with two fields tweaked.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
      "aspectRatio": "9:16",
      "brandId": null,
      "assetStrategy": "regenerate",
      "visualStyle": "moving",
      "animationStop": 2,
      "generationPresetId": "default",
      "delivery": "auto-render",
      "subtitleStyleOverride": {
        "fontFamily": "Bebas Neue",
        "fontSize": 108,
        "textColor": "#00bfff",
        "outlineColor": "#000000",
        "outlineWidth": 8,
        "shadowColor": "#000000",
        "shadowDistance": 0,
        "shadowBlur": 0,
        "animationIn":  { "type": "scale", "durationMs": 200 },
        "animationOut": { "type": "fade",  "durationMs": 150 },
        "vttDisplayMode": "reveal",
        "fontWeight": 700,
        "wordsPerPage": 3
      }
    }
  }' | jq
```

#### Re-render behavior

On re-render of an existing project (e.g. via `POST /api/v1/render` after an Express submit), the renderer reads the subtitle style from the project's stored slot, **not** from a `subtitleStyleOverride` you might send. Submit the override on the original `/api/v1/express` call; subsequent renders of that project will reuse it.

#### Source of truth

Field ranges and the canonical preset list live in `src/lib/services/express/schemas.ts` and `src/components/flows/shared/subtitles/state/subtitlePresets.ts`. The 422 response always returns the current font catalog, so when in doubt copy the names from there rather than this doc.

### 5.14. `delivery` (required, `"auto-render" | "review-first"`)

- `auto-render`: pipeline runs to completion, returns a finished MP4
- `review-first`: pipeline stops after asset generation; you must explicitly call `POST /api/v1/render` to produce the MP4

### 5.15. `resolutionPreset` (optional, `"1080p" | "2K"`, default `"1080p"`)

Final render resolution.

- `1080p` (default): 1920x1080 (16:9) or 1080x1920 (9:16). The historical default. Use this for Reels / Shorts / TikTok / standard YouTube.
- `2K`: 2560x1440 (16:9) or 1440x2560 (9:16). Higher fidelity, sharper text and AI imagery.

Credit cost goes up with `2K` when the run actually generates AI images — that is, `visualStyle: "moving"` or `visualStyle: "static"` with the default (generated) cover. The `estimateSnapshot.reservedAtSubmit` in the submit response reflects the higher tier automatically. Runs that don't generate imagery (`videoLoop`, or `static` with `staticImageSource: "upload"`) cost the same in `1080p` and `2K`.

Omit the field to keep the historical 1080p behavior. Sending anything other than `"1080p"` or `"2K"` returns `400 invalid_settings`.

### 5.16. `presetId` (optional, UUID)

Apply a preset you saved from the Dayvid web app's Single Track creator. A preset captures a re-usable "look" so you don't repeat the same overlays / outro / subtitle style on every submit.

#### What the preset supplies

When `presetId` is set, the preset fills the slots below as the BASE; any explicit field you send on the same request OVERRIDES the preset for that slot.

- **Overlays and outro**: the layers you saved (logo badges, banners, an outro card, etc.) are added to the render.
- **Background animation**: the in/out animation saved on the preset.
- **Subtitle style** (base): the subtitle styling saved on the preset fills `subtitle.style`. Sending `subtitlePresetName` or `subtitleStyleOverride` on the same request overrides it.
- **Aspect ratio** (fallback): used if you omit `aspectRatio` on the request.

The preset never overrides `visualStyle` or `resolutionPreset` — those always come from the request.

#### Fields that become OPTIONAL under `presetId`

- `aspectRatio` — falls back to the preset's saved aspect ratio. Either source has to provide one; otherwise `400 aspect_ratio_required`.
- `subtitlePresetName` / `subtitleStyleOverride` — the preset supplies the subtitle style. If you DO send one, it overrides. Sending both still returns `400 subtitle_source_conflict`.

#### How to get a preset UUID

You create and manage presets in the web app: open the Single Track creator, set up the look you want (subtitles, overlays, outro, aspect ratio), then click "Save as preset" on the preset card. The web app lists every preset you saved on that same picker.

There is no public listing endpoint yet. If you need the UUID for API integration today, contact Dayvid support with the preset name and we'll send it back. A `GET /api/v1/presets` listing endpoint is on the roadmap; once it lands, this section will show the curl call.

#### Errors

- `404 preset_not_found` — the UUID doesn't exist OR the preset isn't yours. Same response for both; we don't confirm whether somebody else's preset exists.
- `400 preset_wrong_type` — the UUID is a preset you own, but it belongs to a different project type (only Single Track presets work on `/api/v1/express`). Body: `{ "expected": "single-track", "actual": "<type>" }`.
- `400 preset_element_forbidden` — the preset references an asset that isn't yours. Rare; usually means the preset row was hand-edited. Body: `{ "forbidden": [<paths>] }`.
- `400 preset_asset_unavailable` — the preset references an asset that has been deleted from storage. Re-save the preset in the web app to refresh it. Body: `{ "missing": [<paths>] }`.

#### Notes

- `presetId` and `Idempotency-Key` play well together: any preset-related error happens BEFORE the idempotency key is committed, so a failed retry with a corrected request reuses the key cleanly.
- `presetId` is independent of `brandId`: you can combine them. The preset supplies the overlay/outro/subtitle look; `brandId` still steers scene generation when `assetStrategy === "reuse"`.

## 6. Job Status and Download

A full Express run takes between 10 and 40 minutes depending on track length, visual style, and provider load. Do NOT busy-poll the job in a tight loop. We email you when the render finishes (success or failure), and the email links straight to the download page on the web app.

There are no webhooks or callbacks today: the two completion signals are the email and polling `GET /api/v1/jobs/:id`. If your integration needs push-style notifications, poll on a relaxed schedule (every few minutes).

Job `status` is one of:

| Value        | Meaning                                                                                      |
|--------------|----------------------------------------------------------------------------------------------|
| `pending`    | Queued, not started yet.                                                                      |
| `processing` | Running. `progress` (0-100) and `progressMessage` tell you which stage it is in.              |
| `completed`  | Finished. `downloadUrl` is present (see below).                                               |
| `failed`     | Terminal error. `userError` carries a human-readable reason. Cancelled jobs also land here.   |
| `partial`    | Finished with some outputs missing (collection runs like Music Highlights, see section 11).   |

Telling "slow" from "stuck": while `status` is `processing`, a moving `progress` or a changing `progressMessage` between polls means the run is alive, just slow. Only `failed` is a dead end. If a job sits at the same progress far beyond the 40-minute ceiling, contact support with the `jobId` instead of re-submitting (a re-submit reserves credits again).

If your integration needs the MP4 URL programmatically, query the job once you receive the email notification, or schedule a status check well after submit:

```bash
JOB_ID="paste-from-submit-response"

curl -s "$DAYVID_BASE/api/v1/jobs/$JOB_ID" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

When `status === "completed"`, the response includes `downloadUrl` (a short-lived signed URL):

```json
{
  "id": "uuid",
  "status": "completed",
  "progress": 100,
  "progressMessage": "Render finished",
  "userError": null,
  "resultPath": "user-id/renders/abc.mp4",
  "publicMetadata": { "durationMs": 187_000 },
  "downloadUrl": "https://...supabase.co/...signed..."
}
```

`downloadUrl` is short-lived — re-query the same job id to get a fresh one.

If you absolutely need to poll without waiting for the email, do so at most once every 30 seconds. The rate limit is 60 GET/min per user, but most of that headroom is for transient retries, not for tight loops over a job that takes tens of minutes.

### 6.1. Re-rendering a project: `POST /api/v1/render`

Starts a new render of a project that already exists (created via `/v1/express` or in the web app). Use it after editing the project in the web editor, or to retry a render.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/render" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{ \"projectId\": \"$PROJECT_ID\" }" | jq
```

Returns 202 with `{ jobId, projectId, statusUrl, message }`. Poll `statusUrl` exactly like any other job (section 6). Re-rendering an already-rendered project is allowed and produces a new MP4; the project detail endpoint (10.7) always points at the latest finished render.

Errors: `400` missing `projectId`, `404` project not found or not yours (same response in both cases). Rate limit: 10 POST/min per user.

## 7. Cancelling a Job

```bash
curl -s -X PATCH "$DAYVID_BASE/api/v1/jobs/$JOB_ID" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Returns `{ "success": true }`. Cancellation refunds reserved credits. Rate limit: 10 PATCH/min per user.

## 8. Discovering Your Brands

`GET /api/v1/brands` lists every brand owned by the caller. Use it once after token setup to grab the `brandId` values you pass into `POST /api/v1/express` (when you want brand-font subtitles, see 5.13) and `POST /api/v1/publish` (where `brandId` is required). The same response tells you which brand fonts you can drop into `subtitleStyleOverride.fontFamily` and which social platforms each brand has connected.

```bash
curl -s "$DAYVID_BASE/api/v1/brands" \
  -H "Authorization: Bearer $DAYVID_TOKEN"
```

Returns 200 with a bare JSON array (no envelope), ordered by most recently updated. Up to 100 brands are returned; pagination is not currently supported.

```json
[
  {
    "id": "0a48e9b7-9b3e-4e3a-9c2f-1f9d8d2e7c10",
    "name": "Acme Records",
    "createdAt": "2026-04-12T08:14:22.000Z",
    "url": "https://dayvid.ai/brands/0a48e9b7-9b3e-4e3a-9c2f-1f9d8d2e7c10",
    "fonts": ["Acme Display", "Acme Body"],
    "integrations": {
      "youtube":   { "connected": true },
      "instagram": { "connected": false }
    }
  }
]
```

### 8.1. Field reference

| Field | Meaning |
|---|---|
| `id` | The UUID to pass as `brandId` in other endpoints. |
| `name` | Human-readable brand label (not unique, may collide across users). |
| `createdAt` | ISO-8601 timestamp the brand was created. The array is sorted by a different (server-internal) `updated_at` key, not by this one. |
| `url` | The brand's page on the Dayvid web app. Open this in a browser to manage the brand — rename it, update the logo, connect OAuth, upload fonts, etc. Treat it as opaque: there is no "API surface" reachable from this URL. |
| `fonts` | Brand-uploaded fonts available for `subtitleStyleOverride.fontFamily`. Strings; case-sensitive. Built-in Google Fonts and system fonts listed in 5.13 are always available even when this list is empty. |
| `integrations.<provider>.connected` | `true` when the brand has an active OAuth row for that provider — i.e. publishing to that provider will not return `oauth_required`. `false` when the row is missing or expired. |

### 8.2. Which providers appear

Only providers that are currently enabled for the caller appear in `integrations`. Today this typically means `youtube` (and Instagram once it ships to your account). If you call `POST /api/v1/publish` for a provider that is not listed here, the publish endpoint returns `422 platform_not_available`. If it is listed but `connected: false`, publish returns `422 oauth_required` with the same `url` so you can send your user to connect.

The set of enabled providers is per-user — a teammate with a different rollout flag may see a different `integrations` shape for the same brand.

### 8.3. Empty cases

- New account with no brands → `[]` (status 200, not 404). Create one in the web UI first.
- Brand with no uploaded fonts → `"fonts": []`. Built-in fonts still work in `subtitleStyleOverride.fontFamily`.
- Brand with no connected platforms → `"integrations": { "youtube": { "connected": false } }` (still emits enabled providers with `connected: false`).

Rate limit: 30 GET/min per user, 60 GET/min per IP.

### 8.4. Listing a Brand's Assets (Reference Images)

`GET /api/v1/brands/{brandId}/assets` lists the **reference images** attached to a brand. These are the images you upload in the web UI under a brand's "Reference Images" section — each one has a `name`, an optional free-text `description`, and an optional `whenToUse` hint. The AI scene generators read those hints to decide which reference fits a given lyric, which is exactly what the `assetStrategy: "reuse"` flow (see 5.6) draws on. Use this endpoint to discover, by name, what a brand has to work with, and to fetch the actual image bytes.

```bash
BRAND_ID="0a48e9b7-9b3e-4e3a-9c2f-1f9d8d2e7c10"

curl -s "$DAYVID_BASE/api/v1/brands/$BRAND_ID/assets" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Returns 200 with a bare JSON array (no envelope), ordered oldest-first — the same order the web UI shows them. The list is small by construction, so there is no pagination.

```json
[
  {
    "id": "5f1c2d3e-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
    "name": "Young Backpacker",
    "description": "A wandering protagonist in a worn green jacket",
    "whenToUse": "Use for travel and journey verses",
    "createdAt": "2026-04-12T08:14:22.000Z",
    "imageUrl": "https://...supabase.co/...signed..."
  }
]
```

#### 8.4.1. Field reference

| Field | Meaning |
|---|---|
| `id` | UUID of the reference image. |
| `name` | The label you gave the asset. This is the handle the AI generators match against when deciding which reference to reuse. |
| `description` | Optional free-text description of what the image depicts. `null` when not set. |
| `whenToUse` | Optional hint that tells the generator which scenes the asset suits. `null` when not set. |
| `createdAt` | ISO-8601 timestamp the reference was added to the brand. |
| `imageUrl` | A short-lived signed URL to the image bytes. **Expires** — re-query this endpoint to refresh it. `null` (rare) when the underlying object could not be signed; the rest of the array is unaffected. |

#### 8.4.2. Empty and error cases

- Owned brand with no reference images → `[]` (status 200, not 404). Add some in the web UI first.
- Brand id that doesn't exist, OR a brand owned by a different account → `404` (no existence leak between tenants).

Rate limit: 30 GET/min per user, 60 GET/min per IP.

## 9. Bringing Your Own Brand: Characters & Visual Style

By default Dayvid invents the visuals for every scene from scratch. A **brand** lets you steer that, so the same recurring characters and visual-style direction carry across every clip. A brand is a reusable kit you build once and then reference by `brandId` on as many videos as you like.

**You set a brand up in the web UI, not through the API.** The API reads brands you already created; there is no endpoint to create a brand or upload images. Do the one-time setup at https://dayvid.ai/brands, then the API consumes it.

### 9.1. Step 1: Create the brand (web UI)

Open https://dayvid.ai/brands and click **New Brand**. That drops you into the brand editor. The fields that actually shape an API-generated music video are:

- **Brand Voice / Visual Style**: a free-text field (Markdown supported) where you describe the brand's tone and visual style. This is fed to the scene generator and is your strongest lever on how the video looks.
- **Reference Images**: your recurring characters and assets (its own step below).
- **Brand Fonts**: link custom fonts. Once linked they can be used in subtitles, but only when you name them in `subtitleStyleOverride.fontFamily` (see 5.13); `GET /api/v1/brands` lists them under `fonts`. They are not auto-applied to captions.

The editor also lets you set a **Name** and brand **colors** and **logo**. For videos generated through this API, focus on the visual-style text, reference images, and fonts above.

### 9.2. Step 2: Upload your reference images (characters and assets)

In the brand editor, the **Reference Images** section is where you upload the characters, props, or style references you want to reuse. When you add an image you fill in:

- **Name** (required): a short label, e.g. `Main character`, `Logo icon`, `Mascot`. This is the handle the AI matches against when it decides which reference to pull into a scene.
- **When to use** (optional but recommended): a plain-language hint for which scenes the asset suits, e.g. `Use in intro scenes`. Per the in-app tooltip, this is what helps the AI decide when to use the image while generating scenes or backgrounds.

There is also a **Description** field, but you do not write it on upload: Dayvid auto-generates a description of the image (you can edit it later in the editor). So at upload time you mainly provide a clear **Name** and, ideally, a **When to use** hint.

### 9.3. Step 3: Reference the brand in your submit

Grab the brand's UUID (from its page at https://dayvid.ai/brands, or programmatically via `GET /api/v1/brands`, see 8) and pass it as `brandId`. Set `assetStrategy: "reuse"` to reuse your stored reference images as-is where they match a scene, versus `"regenerate"` which re-styles them to the scene (see 9.4 and 5.6):

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
      "aspectRatio": "9:16",
      "brandId": "your-brand-uuid",
      "assetStrategy": "reuse",
      "visualStyle": "moving",
      "animationStop": 2,
      "subtitlePresetName": "Gold",
      "delivery": "auto-render"
    }
  }'
```

You can confirm what a brand has to work with before submitting by listing its reference images with `GET /api/v1/brands/{brandId}/assets` (see 8.4).

### 9.4. How Dayvid decides when to use each asset

You do not assign assets to scenes by hand. When `brandId` is set, the scene generator sees your reference images and your visual-style text and, scene by scene, links characters or objects in the lyrics to the references that fit, matching against each asset's name and "when to use" hint (plus its auto-generated description).

`assetStrategy` then decides how a matched reference is used:

- `"reuse"`: drop in your stored reference image **as-is**, no regeneration.
- `"regenerate"` (the default): use your reference image as a **guide to generate an adapted variant** in the video's style, rather than the original file.

So your reference images and visual-style direction inform the result in both modes (whenever `brandId` is set); the difference is whether the exact stored image is reused or re-styled to match the scene. Scenes with no matching reference are generated from scratch.

Tip: clearer "when to use" hints lead to better matching. If an asset shows up in scenes you did not intend, tighten its hint in the web UI and re-render.

## 10. Listing Your Projects

`GET /api/v1/projects` returns the caller's projects, newest first, with cursor pagination. Use it to recover the `projectId` your script forgot to persist, build a CLI dashboard, or discover which renders are already done so you can fan out `/v1/publish` over them.

### 10.1. Minimal call

```bash
curl -s "$DAYVID_BASE/api/v1/projects" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Response (200):

```json
{
  "items": [
    {
      "id": "0a48e9b7-9b3e-4e3a-9c2f-1f9d8d2e7c10",
      "name": "Hung Up On You",
      "type": "single-track",
      "createdAt": "2026-05-20T13:42:11.000Z",
      "updatedAt": "2026-05-20T14:05:33.000Z",
      "aspectRatio": "9:16",
      "brandId": "8c1e...",
      "status": "completed",
      "hasCompletedRender": true,
      "url": "https://dayvid.ai/projects/0a48e9b7-9b3e-4e3a-9c2f-1f9d8d2e7c10"
    }
  ],
  "nextCursor": "eyJ1IjoiMjAyNi0wNS0yMFQxNDowNTozMy4wMDBaIiwiaSI6IjBhNDhlOWI3LTliM2UtNGUzYS05YzJmLTFmOWQ4ZDJlN2MxMCJ9"
}
```

### 10.2. Query parameters

All optional. Combine freely.

| Param         | Type   | Notes                                                                                          |
|---------------|--------|------------------------------------------------------------------------------------------------|
| `limit`       | int    | 1 – 100. Default 50.                                                                            |
| `cursor`      | string | Opaque token from a prior response's `nextCursor`. Treat as opaque — do not parse or modify.    |
| `status`      | enum   | `draft \| processing \| completed \| failed`. Filter by derived per-project status (see 10.3).   |
| `type`        | enum   | `single-track \| music-highlights`. Filter by project kind.                                      |
| `aspectRatio` | enum   | `"9:16"` or `"16:9"`. Exact match.                                                              |
| `q`           | string | Case-insensitive substring match against `name`. ≤ 200 chars. Empty string is ignored.          |

Example: list the next page of vertical, already-rendered projects.

```bash
curl -s -G "$DAYVID_BASE/api/v1/projects" \
  --data-urlencode "status=completed" \
  --data-urlencode "aspectRatio=9:16" \
  --data-urlencode "limit=20" \
  --data-urlencode "cursor=$PREV_CURSOR" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

### 10.3. Field reference

| Field                | Meaning                                                                                                    |
|----------------------|------------------------------------------------------------------------------------------------------------|
| `id`                 | UUID of the project. Pass to `/v1/render`, `/v1/publish`, `/v1/projects/:id`, or look up jobs scoped to it. |
| `name`               | Display name. May change after submit while the server processes the transcript.                            |
| `type`               | `single-track` (one video per project) or `music-highlights` (several clips per project, see section 11).   |
| `createdAt`          | ISO-8601, never changes.                                                                                   |
| `updatedAt`          | ISO-8601, server bump on any edit, re-render, or publish. Drives ordering.                                 |
| `aspectRatio`        | `"9:16" \| "16:9" \| null`.                                                                                 |
| `brandId`            | UUID or `null` if the project was submitted without a brand.                                                |
| `status`             | Derived. See enum below.                                                                                    |
| `hasCompletedRender` | `true` once any render job for the project has reached `completed`. Gates `/v1/publish` (which returns `422 no_completed_render` otherwise). |
| `url`                | The project's page on the web app. Opaque — there is no API surface reachable from this URL.                |

Status enum:

| Value        | Meaning                                                                                                    |
|--------------|------------------------------------------------------------------------------------------------------------|
| `processing` | A run is in flight for this project.                                                                        |
| `failed`     | The last run failed and has not been retried.                                                               |
| `completed`  | At least one render finished. Eligible for `/v1/publish`.                                                   |
| `draft`      | Never finished a render and nothing is currently running.                                                   |

`status` and `hasCompletedRender` can both be `true` for the same project — e.g. a re-render is in flight (`status: "processing"`) but a prior render already succeeded (`hasCompletedRender: true`).

### 10.4. Pagination semantics

Pages are stable under concurrent writes: a project bumped to the top while you're paginating won't reappear on a later page.

- When `nextCursor` is `null`, there are no more pages.
- The token is opaque. Do not parse it, modify it, or rely on its structure — the encoding may change without notice.
- Passing a malformed cursor returns `400 invalid_cursor`. Drop the cursor and start over.
- There is no `prevCursor`. To go back, restart from the beginning.

### 10.5. Empty cases

- Brand-new account with no projects → `{ "items": [], "nextCursor": null }` (200, not 404).
- `status=completed` for an account that has never finished a render → same empty response.
- Search `q` that matches nothing → same empty response.

### 10.6. Errors

| Status | Code            | Cause                                                                                         |
|--------|-----------------|------------------------------------------------------------------------------------------------|
| 400    | `invalid_query` | A query param failed validation. Body includes `field` and `message`.                          |
| 400    | `invalid_cursor`| The `cursor` token is malformed or no longer decodable. Drop it and re-list from the start.    |

Rate limit: `60 GET/min` per user, `120 GET/min` per IP.

### 10.7. Project detail: `GET /api/v1/projects/:id`

One call that answers, for a `projectId` you kept from months ago: which track is this, did it render, where is the MP4, and did I already publish it (and where).

```bash
curl -s "$DAYVID_BASE/api/v1/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Response (200):

```json
{
  "id": "0a48e9b7-9b3e-4e3a-9c2f-1f9d8d2e7c10",
  "name": "Hung Up On You",
  "type": "single-track",
  "status": "completed",
  "aspectRatio": "9:16",
  "brandId": "8c1e...",
  "createdAt": "2026-05-20T13:42:11.000Z",
  "updatedAt": "2026-05-20T14:05:33.000Z",
  "audio": { "fileName": "hung-up-on-you.mp3", "durationMs": 183000 },
  "render": {
    "completed": true,
    "downloadUrl": "https://...signed...mp4",
    "expiresAt": "2026-06-11T15:00:00.000Z",
    "completedAt": "2026-05-20T14:05:33.000Z"
  },
  "highlights": null,
  "publishTargets": [
    {
      "provider": "youtube",
      "status": "published",
      "externalUrl": "https://youtube.com/watch?v=dQw4w9WgXcQ",
      "jobId": "uuid",
      "createdAt": "2026-05-21T10:00:00.000Z"
    }
  ],
  "url": "https://dayvid.ai/projects/0a48e9b7-9b3e-4e3a-9c2f-1f9d8d2e7c10"
}
```

Field notes:

- `status` uses the same enum and derivation as the list endpoint (10.3) - the two never disagree.
- `audio` is `null` when the project has no audio attached yet. `audio.durationMs` is `null` when the duration is unknown (e.g. the project was assembled in the web editor rather than submitted through the API); it is always set for API-submitted projects.
- `render.downloadUrl` is a short-lived signed URL to the latest finished render; `expiresAt` tells you when it stops working - re-fetch this endpoint for a fresh one. All `null` (`completed: false`) while nothing has rendered.
- `highlights` is non-null only for `type: "music-highlights"` projects and carries the same `{ status, partial, progress, clips[] }` shape as the dedicated results endpoint (11.2).
- `publishTargets` flattens every publish run for this project, newest first - one entry per (run, platform). Scan it for `status: "published"` to answer "did I already publish this?" before calling `/v1/publish`. Empty array = never published.
- A project that is missing or not yours returns `404 project_not_found` (same response in both cases, so existence never leaks).

Rate limit: `60 GET/min` per user, `120 GET/min` per IP.

### 10.8. Download a project's lyrics: `GET /api/v1/projects/:id/lyrics`

The time-aligned transcription of the project's audio, as JSON. This is the exact data the burned-in captions are built from, so you can reuse it for karaoke views, your own subtitle files, or syncing lyrics to a CMS, without transcribing the track again.

```bash
curl -s "$DAYVID_BASE/api/v1/projects/$PROJECT_ID/lyrics" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Response (200) is the subtitle JSON content verbatim: an array of time-aligned entries, each with per-word timings.

```json
[
  {
    "index": 1,
    "startMs": 18900,
    "endMs": 22880,
    "text": "One man's trash is another's treasure.",
    "words": [
      { "word": "One", "startMs": 18900, "endMs": 19560 },
      { "word": "man's", "startMs": 19680, "endMs": 20040 },
      { "word": "trash", "startMs": 20120, "endMs": 20880 }
    ]
  }
]
```

- Timestamps are milliseconds from the start of the audio. `words` carries the word-level timing the karaoke-style caption modes use.
- Lyrics exist only after a run that transcribed the audio has completed (an Express or Music Highlights submit). Before that, the endpoint returns `404 lyrics_not_available` with a message explaining why; re-fetch after the job completes.
- A project that is missing or not yours returns `404 project_not_found` (same response in both cases, so existence never leaks).

Rate limit: `60 GET/min` per user, `120 GET/min` per IP.

## 11. Music Highlights

Turn ONE song into several short, ready-to-post highlight videos. Dayvid finds the strongest moments of the track (chorus, build-up, the money line), snaps each to sentence boundaries so cuts never land mid-word, and renders each as a standalone vertical (or horizontal) MP4 with burned-in subtitles over a visual background.

This is a separate one-shot pipeline from the single-video Express endpoint. One POST submits the job; you get a `jobId` immediately and an email when it finishes. Because the output is a COLLECTION of clips (not one video), you fetch them from a dedicated results endpoint. Each clip is a downloadable MP4 with the subtitles already burned in.

Music Highlights is gated behind a per-account feature flag while it rolls out. If your account is not enabled yet, every Music Highlights call returns `404 feature_disabled` (this is on top of needing an API-capable plan). Ask support to enable it for your account.

### 11.1. Submit

`POST /api/v1/music-highlights/express`. Provide exactly one of `audioStoragePath` (from `/api/v1/audio-uploads`, see 4.4) or `audioUrl` (a Suno / Udio / SoundCloud link, or a whitelisted CDN URL). Body:

```json
{
  "settings": {
    "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
    "targetClipCount": 3,
    "maxDurationS": 55,
    "aspectRatio": "9:16",
    "backgroundType": "generated-moving",
    "animationStop": 1
  }
}
```

Fields:

- `audioStoragePath` XOR `audioUrl` (required, one of). `audioDurationMs` is NOT accepted; the server probes the real duration.
- `projectName` (optional string, max 200 chars): display name for the project. If omitted, the project is named `Music Highlights`.
- `targetClipCount` (optional integer 1..4, default 3): how many clips to render. Dayvid ranks the candidate moments and renders the top N.
- `maxDurationS` (optional integer 15..90, default 55): soft cap each clip respects when snapping to sentence boundaries.
- `aspectRatio` (optional, `"9:16" | "16:9"`, default `"9:16"`).
- `backgroundType` (required, `"generated-moving" | "video-loop"`): `"generated-moving"` paints AI scenes and can animate the strongest shots as AI video clips; `"video-loop"` plays a looping stock background and requires `videoLoopSlug`.
- `animationStop` (optional `0 | 1 | 2 | 3 | 4`, default `1`): only meaningful when `backgroundType === "generated-moving"`. It uses the same motion tier as `/api/v1/express`: `0` disables AI video animation, `1` animates the best moments, higher tiers animate progressively more shots. For `video-loop`, omit it or send `null`.
- `videoLoopSlug` (required when `backgroundType === "video-loop"`): a catalog loop slug, e.g. `train`, `snow-pine`, `three-moons`, `nyc-taxi`, `submersible`, `dancefloor`. Omitting it returns `400 video_loop_slug_required`.
- `brandId` (optional UUID or `null`, default `null`): a brand you own; steers recurring characters / visual style. Must belong to you or the call returns `400 brand_not_owned`.
- `assetStrategy` (optional, `"reuse" | "regenerate"`, default `"regenerate"`): reuse a brand's existing reference assets instead of regenerating them (only meaningful with a `brandId`).
- `generationPresetId` (optional string, default `"default"`): visual generation style preset.
- `visualGuidelines` (optional string, max 1000 chars): free-text steering for the generated visuals.
- `subtitleEnabled` (optional boolean, default `true`).
- `subtitleStyle` (optional object or `null`): a full subtitle style override, same shape as `subtitleStyleOverride` on `/api/v1/express` (see 5.13). When set, its `fontFamily` is validated against the font catalog; an unknown font returns `422 font_not_available`.
- `language` (optional two-letter ISO-639-1 code or `null`, default autodetect): transcription hint. Pass a code when autodetect mislabels a track (e.g. a mostly-Portuguese song with English-sounding intro ad-libs).

Response (202):

```json
{
  "projectId": "uuid",
  "jobId": "uuid",
  "estimateSnapshot": { "total": 12, "reservedAtSubmit": 12, "marginCredits": 0, "breakdown": [] },
  "pollUrl": "/api/v1/jobs/<jobId>",
  "resultsUrl": "/api/v1/music-highlights/<projectId>"
}
```

Credits are reserved up front against the estimate and the unused slack is refunded when the run finishes. Poll `pollUrl` for status; fetch the clips from `resultsUrl` once it reports `completed`.

### 11.2. Fetch results

`GET /api/v1/music-highlights/:projectId` returns the rendered clips with per-clip signed download URLs. The single-artifact `downloadUrl` on the job poll can't carry several clips, so this is the collection endpoint. Poll `/api/v1/jobs/:id` until `status === "completed"`, then read here (rate limit 60/min).

```bash
curl -s "$DAYVID_BASE/api/v1/music-highlights/$PROJECT_ID" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Response:

```json
{
  "projectId": "uuid",
  "status": "processing",
  "partial": false,
  "progress": "2/3",
  "clips": [
    {
      "position": 0,
      "startMs": 134000,
      "endMs": 176000,
      "durationMs": 42000,
      "selected": true,
      "status": "rendered",
      "downloadUrl": "https://...signed...mp4"
    },
    {
      "position": 1,
      "startMs": 201000,
      "endMs": 240000,
      "durationMs": 39000,
      "selected": true,
      "status": "processing",
      "downloadUrl": null
    }
  ]
}
```

- `status`: `"processing"` while the run is in flight, `"completed"` when every selected clip rendered, `"partial"` when the run finished but a clip failed, `"failed"` on error.
- `progress`: `"rendered/total"` over the selected clips (e.g. `"2/3"`). Poll on this instead of counting non-null `downloadUrl`s.
- `clips[].status`: per-clip lifecycle - `"rendered"` (MP4 ready, downloadable even mid-run), `"processing"` (not rendered yet, run still going), `"failed"` (the run already ended and this clip never rendered - it will not appear without a new submit). This is how you tell "slow" from "stuck": a clip stuck at `"processing"` while `status` is still `"processing"` is just slow; anything `"failed"` is final.
- `partial`: `true` only when the run finished with at least one clip missing.
- `downloadUrl`: a short-lived signed URL to the rendered MP4 (subtitles burned in). It expires, so re-query this endpoint to refresh.
- A project that is missing, not yours, or not a Music Highlights project returns `404 project_not_found` (the same response in all three cases, so existence never leaks).

### 11.3. Recipe: highlights from a Suno track

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/music-highlights/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
      "targetClipCount": 3,
      "maxDurationS": 45,
      "backgroundType": "generated-moving",
      "animationStop": 1
    }
  }' | tee /tmp/mh.json | jq

PROJECT_ID=$(jq -r .projectId /tmp/mh.json)

# Wait for the job email (or poll pollUrl), then download every rendered clip:
curl -s "$DAYVID_BASE/api/v1/music-highlights/$PROJECT_ID" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  | jq -r '.clips[] | select(.downloadUrl) | "\(.position) \(.downloadUrl)"' \
  | while read POS URL; do curl -s -o "clip_$POS.mp4" "$URL"; echo "saved clip_$POS.mp4"; done
```

### 11.4. Idempotency for music highlights

Same `Idempotency-Key` header as `/api/v1/express`. Format and replay semantics are identical. The scope is independent: a key reused across the two endpoints does NOT collide.

## 12. Publishing to Social Platforms

> **Prerequisite — connect platforms via the web UI, not the API.**
>
> Before your first `POST /api/v1/publish`, sign in at <https://dayvid.ai> and create a brand. Each brand has its own page at `https://dayvid.ai/brands/<brandId>` with a "Connected Accounts" section where you connect YouTube, Instagram, and TikTok via OAuth. Connections are scoped to the brand (unique per `brand_id, provider`), so if you publish using a different `brandId` you have to connect it on that brand too. The API never accepts platform tokens from clients and does not expose any endpoint to start the OAuth handshake. If you call publish for a platform your brand has not connected, you receive `422 oauth_required` with `connectUrl: https://dayvid.ai/brands/<brandId>` pointing at the right page.

`POST /api/v1/publish` takes a project that already finished rendering (see section 6) and publishes the rendered MP4 to one or more social platforms in parallel. Returns 202 with `jobId` you poll the same way as any other v1 job.

### 12.1. Minimal request (YouTube)

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/publish" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "<projectId from express response>",
    "brandId":   "<brandId you connected platforms on>",
    "providers": ["youtube"],
    "title":     "Hung Up On You",
    "description": "AI music video, made with Dayvid.",
    "hashtags":  ["musicvideo", "indie"],
    "tags":      ["music", "indie", "ai"]
  }' | jq
```

Response (202):

```json
{
  "jobId": "uuid",
  "projectId": "uuid",
  "status": "processing",
  "pollUrl": "/api/v1/jobs/<jobId>"
}
```

### 12.2. Multiple platforms in one call

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/publish" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "<projectId>",
    "brandId":   "<brandId>",
    "providers": ["youtube", "instagram", "tiktok"],
    "title":     "Track title",
    "description": "Description seen on every platform.",
    "hashtags":  ["music"],
    "metadata": {
      "thumbnailStoragePath": "<userId>/images/...",
      "youtube":   { "privacyStatus": "public", "playlistIds": ["PLxxx"] },
      "instagram": { "coverUrl": "https://..." },
      "tiktok":    { "postMode": "post" }
    }
  }' | jq
```

`metadata` is namespaced per platform: anything inside `metadata.youtube` is forwarded to YouTube, anything inside `metadata.tiktok` to TikTok, etc. Unknown platforms pass through validation but are rejected at runtime with `platform_not_available`.

### 12.3. Polling a publish job

```bash
curl -s "$DAYVID_BASE/api/v1/jobs/$JOB_ID" \
  -H "Authorization: Bearer $DAYVID_TOKEN" | jq
```

Publish jobs return a different shape than express/render jobs. The discriminator is `jobType: "publish"` and the payload exposes `targets[]` (one entry per platform):

```json
{
  "id": "uuid",
  "jobType": "publish",
  "status": "published",
  "targets": [
    {
      "provider": "youtube",
      "status":   "published",
      "externalId":  "dQw4w9WgXcQ",
      "externalUrl": "https://youtube.com/watch?v=dQw4w9WgXcQ"
    },
    {
      "provider": "instagram",
      "status":   "failed",
      "error":    "Token expired. Reconnect Instagram on the brand page.",
      "errorCategory": "auth_expired"
    }
  ],
  "userError": null,
  "startedAt": "2026-05-23T13:00:00.000Z",
  "completedAt": "2026-05-23T13:02:14.000Z"
}
```

Overall `status` summarizes the run: `"published"` (all succeeded), `"partial"` (some succeeded), `"failed"` (none), `"processing"` (still in flight), `"pending"`.

To attach a custom YouTube thumbnail, see 12.4.

Per-target `status` is one of `pending | validating | uploading | published | draft | partial | failed`. Per-target `error` is a user-facing string; `errorCategory` is one of `auth_expired | quota_exceeded | generic` so your client can route retries.

### 12.4. Custom thumbnail (YouTube)

Attach your own thumbnail instead of letting YouTube pick a frame. Two steps: upload the image, then reference its storage path on publish.

`POST /api/v1/image-uploads` is a general-purpose image upload that uses the same signed-URL model as audio (section 4.4): the request body needs only `fileName` (with an extension).

```bash
# 1. Signed URL for the image (fileName only)
curl -s -X POST "$DAYVID_BASE/api/v1/image-uploads" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "fileName": "thumb.jpg" }' | tee /tmp/thumb.json | jq

THUMB_SIGNED=$(jq -r .signedUploadUrl /tmp/thumb.json)
THUMB_PATH=$(jq -r .storagePath /tmp/thumb.json)

# 2. PUT the bytes (the Content-Type you send becomes the object's type)
curl -s -X PUT "$THUMB_SIGNED" \
  -H "Content-Type: image/jpeg" \
  --data-binary "@/path/to/thumb.jpg"

# 3. Publish, referencing the path as metadata.thumbnailStoragePath
curl -s -X POST "$DAYVID_BASE/api/v1/publish" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"projectId\": \"$PROJECT_ID\",
    \"brandId\":   \"$BRAND_ID\",
    \"providers\": [\"youtube\"],
    \"title\":     \"Hung Up On You\",
    \"metadata\":  { \"thumbnailStoragePath\": \"$THUMB_PATH\" }
  }" | jq
```

Thumbnail requirements:

- It must be an image you uploaded — referencing a path that is not yours returns `400 thumbnail_storage_path_forbidden`.
- It must already be uploaded — if the PUT has not landed yet, `422 thumbnail_not_found`.
- It must be `≤ 10 MB` and one of `image/jpeg`, `image/png`, `image/gif`, else `422 thumbnail_invalid` (with a `reason` in the body). webp is not accepted for YouTube thumbnails.

If the thumbnail fails to apply at YouTube, the publish still succeeds and the failure comes back as a per-target warning.

`metadata.thumbnailStoragePath` is also used as the Instagram cover when you publish to Instagram.

### 12.5. Platforms

| Provider    | Status        |
|-------------|---------------|
| `youtube`   | Live.         |
| `instagram` | In review with Meta. Connects via UI but publishing may be limited until approval lands. |
| `tiktok`    | In review with TikTok. Same caveat as Instagram.                                          |
| Others      | Planned. Calling with `facebook`, `twitter`, `linkedin` returns `platform_not_available`. |

### 12.6. Idempotency for publish

`POST /api/v1/publish` accepts the same `Idempotency-Key` header as the other v1 routes (see section 13). Same payload + same key within 24h returns the same `jobId`; different payload with the same key returns `422 idempotency_mismatch`. Use this when retrying after a network hiccup.

### 12.7. Already-published protection (`force`)

Publishing is the one operation a retry can't undo: a duplicate request means a second real video on the platform, with no way to cancel it through Dayvid. So on top of `Idempotency-Key` (which only helps when you reuse the key), the endpoint checks the project's publish history per requested platform:

- **Every requested platform already has this project live** → `200` with the existing publish instead of dispatching a new one:

  ```json
  {
    "alreadyPublished": true,
    "projectId": "uuid",
    "targets": [
      {
        "provider": "youtube",
        "status": "published",
        "externalUrl": "https://youtube.com/watch?v=dQw4w9WgXcQ",
        "jobId": "uuid",
        "publishedAt": "2026-05-21T10:02:14.000Z"
      }
    ],
    "detailUrl": "/api/v1/projects/<projectId>"
  }
  ```

- **Some (not all) requested platforms already have it** → `409 already_published` with `publishedProviders[]`, `unpublishedProviders[]`, and the `existing[]` entries. Either narrow `providers` to the unpublished ones or pass `force`.

- **You genuinely want a duplicate** (re-upload after deleting on the platform, A/B posting, etc.) → add `"force": true` to the body and the check is skipped entirely.

A platform target counts as "already published" when its upload landed (`published`, or `draft` for platforms that file uploads as drafts). Failed or in-flight targets don't count - retrying those is exactly what the endpoint is for.

## 13. Idempotency

`POST /api/v1/express` accepts an `Idempotency-Key` header. Format: 1-80 chars, `[A-Za-z0-9_-]`. If you retry with the same key within 24h:

- If the original submit completed: you get the cached response with header `Idempotency-Replay: true`
- If still in flight: 409 `idempotency_in_progress`
- If the original errored: the key is released and your retry runs fresh

Use this when retrying after a network hiccup so you do not accidentally enqueue two jobs.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Idempotency-Key: my-track-2026-05-22-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{ "settings": { ... } }'
```

## 14. Error Reference

Every error returns `{ "error": "<code>", ...details }`. That includes routing errors: a request to a path that does not exist under `/api/v1/` returns `404 { "error": "not_found", "message": "No such endpoint: ..." }`, and a wrong HTTP method on a real endpoint returns `405 { "error": "method_not_allowed", "allowed": [...] }` with an `Allow` header - your client never has to parse HTML out of this API.

The codes you are likely to encounter while integrating:

| Status | Code                                  | Meaning                                                  |
|--------|---------------------------------------|----------------------------------------------------------|
| 400    | `invalid_settings`                    | Express body failed validation. `issues[]` lists each problem. |
| 400    | `invalid_body`                        | Publish body failed validation. `issues[]` lists each problem. |
| 400    | `thumbnail_storage_path_forbidden`    | Publish `metadata.thumbnailStoragePath` is not under your `{userId}/` prefix (see 12.4). |
| 400    | `subtitle_source_conflict`            | Express received both `subtitlePresetName` and `subtitleStyleOverride`. Send exactly one. |
| 400    | `subtitle_source_missing`             | Express received neither `subtitlePresetName` nor `subtitleStyleOverride` (and no `presetId` to supply it). Send one of them or set `presetId` (see 5.16). |
| 400    | `aspect_ratio_required`               | Express received neither `aspectRatio` nor a `presetId` whose preset has one saved. Set `aspectRatio` (5.4). |
| 400    | `preset_wrong_type`                   | Express `presetId` points at a preset you own but for a different project type. Only Single Track presets work here. Body: `{ expected, actual }` (see 5.16). |
| 400    | `preset_element_forbidden`            | Express `presetId` references at least one asset that isn't yours. Rare; usually a hand-edited preset. Body: `{ forbidden: [...] }` (see 5.16). |
| 400    | `preset_asset_unavailable`            | Express `presetId` references at least one asset that has been deleted from storage. Re-save the preset in the web app. Body: `{ missing: [...] }` (see 5.16). |
| 404    | `preset_not_found`                    | Express `presetId` does not exist or is not yours. Same response in both cases — we don't confirm whether someone else's preset exists (see 5.16). |
| 400    | `idempotency_key_invalid`             | `Idempotency-Key` does not match the allowed format.     |
| 400    | `brand_not_owned`                     | Music Highlights `brandId` is not a brand you own.       |
| 400    | `video_loop_slug_required`            | Music Highlights `backgroundType: "video-loop"` sent without `videoLoopSlug`. |
| 400    | `invalid_query`                       | `GET /v1/projects` query param failed validation. Body includes `field` and `message`. |
| 400    | `invalid_cursor`                      | `GET /v1/projects` cursor is malformed or no longer decodable. Drop it and re-list from the start. |
| 401    | (no body)                             | Missing or invalid Bearer token.                         |
| 402    | `plan_does_not_include_apiAccess`     | Your plan cannot call the API. Upgrade to Start or above.|
| 402    | `insufficient_credits`                | `{ needed, balance }`. Buy more or wait for refill.      |
| 403    | `action_denied`                       | Publish blocked (banned account, plan, or per-platform cap). `reason` is in the body. |
| 404    | (varies)                              | Project, brand, or job not found.                        |
| 404    | `feature_disabled`                    | Music Highlights is not enabled for your account yet (rollout flag), or the kill switch is on. |
| 404    | `project_not_found`                   | Project detail or Music Highlights results for a project that is missing or not yours (or, for highlights, not a highlights project). |
| 404    | `lyrics_not_available`                | Project lyrics requested before any run transcribed the audio. Re-fetch after a submit completes (see 10.8). |
| 404    | `not_found`                           | The URL path itself does not exist under `/api/v1/`. Check for typos; `message` echoes the path. |
| 405    | `method_not_allowed`                  | Wrong HTTP method on a real endpoint. `allowed[]` lists the supported methods. |
| 409    | `idempotency_in_progress`             | Same key still running.                                  |
| 409    | `already_published`                   | Publish: some requested platforms already carry this project. `publishedProviders[]`, `unpublishedProviders[]`, `existing[]` in body. Narrow `providers` or pass `force: true` (see 12.7). |
| 413    | `audio_too_large`                     | Audio exceeds 100 MB.                                    |
| 415    | `audio_mime_not_supported`            | Audio file format not supported (see section 4.4).       |
| 422    | `probe_failed`                        | Could not read duration from the audio file.             |
| 422    | `no_completed_render`                 | Publish called for a project that has not rendered yet.  |
| 422    | `thumbnail_not_found`                 | Publish `metadata.thumbnailStoragePath` points at an object that does not exist — the image PUT has not landed (see 12.4). |
| 422    | `thumbnail_invalid`                   | Publish thumbnail is `> 10 MB` or not `image/jpeg`/`png`/`gif`. `reason` in body (see 12.4). |
| 422    | `oauth_required`                      | Publish called for a platform not connected on the brand. `missing[]` lists which platforms; `connectUrl` is `https://dayvid.ai/brands/<brandId>` for the brand you submitted. |
| 422    | `platform_not_available`              | Publish called for a platform not enabled for your account or not implemented. `providers[]` lists which. |
| 422    | `content_policy_violation`            | Moderation rejected the publish (e.g. title/description).|
| 422    | `font_not_available`                  | Express `subtitleStyleOverride.fontFamily` is not in the catalog for this brand. Response body lists `availableBuiltin`, `availableSystem`, `availableBrand`. See section 5.13. |
| 429    | `quota_exceeded`                      | Rate limit hit. Backoff and retry.                       |
| 429    | `action_denied` (`reason: RATE_LIMITED`) | Monthly publish cap or per-platform hourly cap reached. `cap`, `used`, `nextAllowedAt` in body. |
| 502    | `audio_url_unreachable`               | We could not download the linked track.                  |

## 15. Rate Limits Summary

| Endpoint                                       | User / min | IP / min |
|------------------------------------------------|------------|----------|
| `GET /api/v1/me`                               | 60         | 120      |
| `GET /api/v1/brands`                           | 30         | 60       |
| `GET /api/v1/brands/:brandId/assets`           | 30         | 60       |
| `GET /api/v1/projects`                         | 60         | 120      |
| `GET /api/v1/projects/:id`                     | 60         | 120      |
| `GET /api/v1/projects/:id/lyrics`              | 60         | 120      |
| `POST /api/v1/audio-uploads`                   | 20         | 40       |
| `POST /api/v1/image-uploads`                   | 20         | 40       |
| `POST /api/v1/express`                         | 5          | 10       |
| `POST /api/v1/music-highlights/express`        | 5          | 10       |
| `GET /api/v1/music-highlights/:projectId`      | 60         | 120      |
| `POST /api/v1/render`                          | 10         | 20       |
| `POST /api/v1/publish`                         | 5          | 10       |
| `GET /api/v1/jobs/:id`                         | 60         | 120      |
| `PATCH /api/v1/jobs/:id`                       | 10         | 20       |

Publish also has plan-level caps (monthly publish count + per-platform hourly window) that surface as `429 action_denied` with `reason: "RATE_LIMITED"`; see `cap`, `used`, `nextAllowedAt` in the body.

## 16. Recipes

### 16.1. How to create a vertical music video from a Suno song (Reels/Shorts/TikTok)

Vertical, AI-generated moving imagery, punchy subtitles, no brand:

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
      "aspectRatio": "9:16",
      "brandId": null,
      "assetStrategy": "regenerate",
      "visualStyle": "moving",
      "animationStop": 2,
      "subtitlePresetName": "Punchy",
      "generationPresetId": "default",
      "delivery": "auto-render"
    }
  }'
```

### 16.2. How to create a horizontal lyric video with a video-loop background (YouTube)

`videoLoop` skips image generation; the train MP4 plays under subtitles. Cheapest run.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://www.udio.com/songs/uXYZabcDEF",
      "aspectRatio": "16:9",
      "brandId": null,
      "assetStrategy": "reuse",
      "visualStyle": "videoLoop",
      "videoLoopPresetSlug": "train",
      "subtitlePresetName": "White",
      "delivery": "auto-render"
    }
  }'
```

### 16.3. How to create an anime-styled video from a custom MP3

Combines the upload flow with an aesthetic generation preset and visual guidelines.

```bash
# 1) Get signed upload URL (FILE set to your local audio path)
curl -s -X POST "$DAYVID_BASE/api/v1/audio-uploads" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "fileName": "song.mp3" }' > /tmp/u.json
SIGNED=$(jq -r .signedUploadUrl /tmp/u.json)
PATH_=$(jq -r .storagePath /tmp/u.json)

# 2) Upload the bytes
curl -s -X PUT "$SIGNED" -H "Content-Type: audio/mpeg" --data-binary "@$FILE"

# 3) Submit
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"settings\": {
      \"audioStoragePath\": \"$PATH_\",
      \"aspectRatio\": \"9:16\",
      \"brandId\": null,
      \"assetStrategy\": \"regenerate\",
      \"visualStyle\": \"moving\",
      \"animationStop\": 2,
      \"subtitlePresetName\": \"Glow\",
      \"generationPresetId\": \"anime\",
      \"visualGuidelines\": \"Anime protagonist with red hoodie, neon Tokyo streets, rainy night\",
      \"delivery\": \"auto-render\"
    }
  }"
```

### 16.4. How to use a saved brand kit

Fetch your brand kit UUID from its page at https://dayvid.ai/brands, then pass it as `brandId`. For the full walkthrough of setting up a brand and reusing your own characters, see section 9, including how `assetStrategy` controls whether matched reference images are reused as-is or re-styled.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
      "aspectRatio": "9:16",
      "brandId": "your-brand-uuid",
      "assetStrategy": "regenerate",
      "visualStyle": "moving",
      "animationStop": 2,
      "subtitlePresetName": "Gold",
      "delivery": "auto-render"
    }
  }'
```

### 16.5. How to review assets before rendering

Use `delivery: "review-first"`. The Express job stops once images and timing are ready, leaving the project in a reviewable state in the web UI. When you want the MP4, call render directly with the `projectId` returned by submit:

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/render" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{ \"projectId\": \"$PROJECT_ID\" }"
```

This returns a new `jobId` for the render. Poll it the same way as section 6.

### 16.6. How to safely retry a flaky submit

Send the same `Idempotency-Key` every attempt. After the first success, all subsequent retries return the cached `projectId` + `jobId` instead of creating new jobs.

```bash
KEY="track-$(date +%Y%m%d)-attempt"
for i in 1 2 3; do
  curl -s -X POST "$DAYVID_BASE/api/v1/express" \
    -H "Authorization: Bearer $DAYVID_TOKEN" \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{ "settings": { ... } }' \
    -w "\nstatus:%{http_code}\n" && break
  sleep 2
done
```

### 16.7. How to apply a saved preset and render in 2K

Pick a preset you already saved in the web app (see 5.16 for how to obtain the UUID). The preset supplies overlays / outro / subtitle style / aspect ratio; the request below adds a Suno track, asks for 2K, and overrides nothing else — so the rendered video looks exactly like the preset on top of the new track.

```bash
curl -s -X POST "$DAYVID_BASE/api/v1/express" \
  -H "Authorization: Bearer $DAYVID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "audioUrl": "https://suno.com/s/E6uWAXuAvVlQyJR6",
      "brandId": null,
      "assetStrategy": "regenerate",
      "visualStyle": "moving",
      "animationStop": 2,
      "generationPresetId": "default",
      "delivery": "auto-render",
      "resolutionPreset": "2K",
      "presetId": "11111111-1111-4111-8111-111111111111"
    }
  }'
```

Two things to notice:

- `aspectRatio` is omitted — the preset's saved aspect ratio fills the slot. Add `"aspectRatio": "9:16"` to override the preset.
- Neither `subtitlePresetName` nor `subtitleStyleOverride` is sent — the preset's subtitle style is used as-is. Add either field to override.

`reservedAtSubmit` in the response will be higher than the same submit without `resolutionPreset: "2K"` because AI image generation steps up to the higher tier.

## 17. Token Lifecycle

- When you create a token at https://dayvid.ai/profile, you choose an expiration: 30 days, 90 days, 1 year, or Never. The default in the dialog is **90 days**, so pick "Never" explicitly if you want a token that does not expire on its own.
- Once a token expires, requests using it return 401 and you mint a new one.
- You can also revoke a token at any time from the same page. Revoking takes effect immediately.
- Treat tokens like passwords: store them in a secret manager, never commit them, and rotate if you suspect one leaked.
