# H3 Video Platform · HTTP API Reference

For AI agents writing their own HTTP clients. If you use the `h3` CLI you don't need this file: the CLI wraps everything below. See [SKILL.md](https://minimax.xxt.ai/SKILL.md).

Base URL: `https://minimax.xxt.ai`. All paths below are relative to it.

## 1. Conventions

### Authentication

Send on every request:

```
Authorization: Bearer <API Key>
```

- Get an API Key by having the user approve a link (see "Link authorization" in section 2; the user doesn't copy a key), or the user generates one on the "API Key" page (「API Key」) of the website; it is shown only once. It spends this account's credits and has the same permissions as the user on the website.
- Wrong or revoked key: `401 invalid_api_key`. Banned account: `403 banned`.

### Requests and responses

- Request bodies are JSON with `Content-Type: application/json`, max 64KB. Images and audio never go through these endpoints; they use upload URLs (section 3).
- Responses are always JSON. Times are ISO 8601 UTC, e.g. `2026-10-25T08:00:00.000Z`.
- Error body:

  ```json
  { "error": "insufficient_credits", "message": "可用积分不够。", "balance": 120, "required": 250 }
  ```

  Decide what to do from `error` (the code). `message` is a human-readable Chinese explanation (here: "Not enough available credits."). Some errors carry extra fields (section 9).
- Invalid parameters (`invalid-input`) include `issues`, saying which field is wrong and why:

  ```json
  {
    "error": "invalid-input",
    "message": "填的内容不对，请检查一下。",
    "issues": [
      { "path": "seconds", "message": "时长要在 4–15 秒之间" },
      { "path": "refs[0].note", "message": "角度选「其他」要填说明" }
    ]
  }
  ```

  (Messages: "The input is invalid, please check it."; "Duration must be 4–15 seconds"; "Angle 'other' needs a note".) `path` is the request-body field (arrays as `[index]`); for a bad header it is the header name, e.g. `Idempotency-Key`. At most 10 issues are listed.

### CLI and skill versions

Every response (errors included) carries two headers. `X-H3-Cli-Sha256` is the sha256 of the live `/h3.py`. `X-H3-Skill-Sha256` is the sha256 of the six skill files (`/SKILL.md`, `/references/prompting.md`, `/references/api.md`, `/references/songwriting.md`, `/references/characters.md`, `/references/motion-swap.md`, in that order, joined with one `\0` byte). A client that caches these files can compare and re-download on mismatch. The `h3` CLI auto-updates this way.

`X-H3-Drama-Sha256` is the fingerprint of the short-drama skill packs (h3-drama, drama-finish, drama-mv), which need login: `GET /api/v1/skill-packs/drama` returns `{ bundle, sha256, packs: [{ name, files: [{ path, sha256, bytes }] }] }` and `GET /api/v1/skill-packs/drama/<pack>/<path>` one file as text (`404 not-found` for anything else). `h3 skill install drama` saves and updates them.

### Pagination

List endpoints return `{ "items": [...], "next": "<cursor>" | null }`. For the next page pass `next` back as `before`. `next` = `null` means no more.

### Retention period

Videos, songs and uploaded files are kept for 8 hours after the job ends, then deleted automatically. Please download them in time.

## 2. Account

### `GET /api/v1/me` · My account

```json
{ "email": "a@example.com", "isAdmin": false, "balance": 500, "held": 250 }
```

- `balance`: available credits. `held`: credits on hold for submitted, not yet settled work. 1 credit = ¥0.01.

### `GET /api/v1/me/usage` · My usage

Cumulative since signup.

```json
{ "jobs": 12, "videos": 15, "videoSeconds": 112.5, "creditsSpent": 2250, "videoCreditsSpent": 1650, "songRuns": 3, "songs": 5 }
```

- `jobs`: number of job submissions (successful or not).
- `videos`: videos delivered. A two-take submission counts as two. Failed and canceled takes don't count.
- `videoSeconds`: total seconds of delivered videos.
- `creditsSpent`: total credits spent, i.e. the sum of all charges (videos and songs). Refunds don't count.
- `videoCreditsSpent`: the part spent on videos (excluding songs).
- `songRuns`: number of song submissions, one per submission (successful or not).
- `songs`: songs received. Each run produces two; only finished ones count.

### `GET /api/v1/me/ledger?limit=50&before=<next>` · Credit ledger

Newest first. `limit` defaults to 50, max 200.

```json
{
  "items": [
    {
      "id": "42",
      "kind": "hold",
      "amount": 250,
      "delta": -250,
      "balanceAfter": 250,
      "heldAfter": 250,
      "note": null,
      "createdAt": "2026-10-25T08:00:00.000Z",
      "job": { "id": "…", "mode": "i2v", "seconds": 5, "segments": 1, "prompt": "提示词开头 30 个字…" },
      "song": null
    }
  ],
  "next": "41"
}
```

- `kind`: `signup` signup bonus; `admin_adjust` admin adjustment; `purchase` credits bought online (`note` is `订单 <order id>`, "order <order id>"); `hold` hold at submission (video or song); `charge` charge on success of a video or song (songs are charged per track, one entry per track); `release` refund on failure or cancel (also for a song track that didn't come out).
- `amount`: the entry's amount (always ≥ 0). `delta`: change in available balance (negative for holds, positive for refunds, 0 for charges; a charge shows as a drop in `heldAfter`).
- `job`: the related job (`job.prompt` is the first 30 characters of the prompt). `null` for songs, signup bonus and admin adjustments.
- `song`: the related song run, `{ "id", "title" }` (`id` is the song run id, `title` the song title, `null` if untitled). `null` for non-song entries.
- Other fields such as `holdId` and `apiKeyId` can be ignored.

### Buying credits (Stripe)

The user pays on a Stripe payment page (card, or any other method the page offers); you only create the order and hand over the link. Credits arrive automatically after payment. Purchased credits don't expire and aren't refundable.

| Endpoint | Purpose | Returns |
|---|---|---|
| `GET /api/v1/billing/options` | Packages, custom-amount limits and whether online payment is available | `{ "available": true, "packages": [{ "credits": 10000, "prices": { "CNY": 10000, "USD": 1500 } }], "custom": { "credits": { "min": 10000, "max": 1000000 }, "amount": { "CNY": { "min": 100, "max": 10000 }, "USD": { "min": 15, "max": 1500 } } } }`; package prices are in the smallest currency unit (fen, cents); `custom.amount` is in whole yuan / dollars |
| `POST /api/v1/billing/checkout`, body `{ "credits": 10000, "currency": "CNY" }` | Create an order. `credits`: a package (10000 = ¥100 / $15, 50000 = ¥500 / $75, 100000 = ¥1000 / $150) or any custom integer from 10000 to 1000000 (¥100–¥10,000 / $15–$1,500). For a custom amount in money: CNY credits = yuan × 100; USD credits = floor(dollars ÷ 0.0015). Below 10000: `400 invalid-input` (`path` = `credits`); `currency` `CNY` (¥0.01 per credit) or `USD` ($0.0015 per credit) | `201 { "order": <order>, "url": "https://checkout.stripe.com/…" }`. Give `url` to the user to open in a browser; don't open or pay it yourself. Not available: `503 payments_unavailable` |
| `GET /api/v1/billing/orders` | My orders, newest first, max 50 | `{ "items": [<order>] }` |
| `GET /api/v1/billing/orders/<id>` | One order; while still `pending` the platform checks with Stripe once, so a paid order turns `paid` right away | `<order>`. Not yours: `404 not-found` |

Order: `{ "id", "credits", "currency", "amount", "status", "createdAt", "paidAt" }`. `amount` is in the smallest currency unit. `status`: `pending` (waiting for payment), `paid` (credits added), `expired` (payment page expired after 24 hours unpaid; create a new order), `mismatch` (payment didn't match the order; no credits added; the user should email hi@xxt.ai).

Don't create orders the user didn't ask for. Ask which package and currency first (Chinese users usually `CNY`).

### API Key management

| Endpoint | Purpose | Returns |
|---|---|---|
| `GET /api/v1/keys` | List unrevoked keys | `[{ "id", "name", "prefix", "createdAt", "lastUsedAt", "totalCharged" }]` |
| `POST /api/v1/keys`, body `{ "name": "笔记本" }` | Create one (name 1–64 chars; must be unique among unrevoked keys) | `201 { "id", "name", "key" }`; `key` appears only this once. Duplicate name: `409 name_taken` |
| `DELETE /api/v1/keys/<id>` | Revoke; takes effect immediately | `{ "ok": true }` |

### Link authorization (user doesn't copy a key)

Works like device authorization (RFC 8628): you start a request and send the link to the user; the user logs in on the website and clicks Authorize (「授权」); you use the device code to receive a new API Key. The start and poll endpoints need no `Authorization`.

| Endpoint | Purpose | Returns |
|---|---|---|
| `POST /api/v1/device-login`, body `{ "clientName": "MacBook-Pro" }` (optional, max 64 chars, shown on the authorization page) | Start | `201 { "deviceCode", "userCode", "verificationUrl", "verificationUrlComplete", "expiresIn": 600, "interval": 5 }` |
| `POST /api/v1/device-login/token`, body `{ "deviceCode": "…" }` | Poll | Not yet approved: `{ "status": "pending" }`. Approved: `{ "status": "approved", "key", "keyId", "keyName" }`; `key` is returned only this once |
| `GET /api/v1/device-login/<userCode>` | Request details (login required) | `{ "userCode", "clientName", "clientCountry", "createdAt", "expiresAt", "status" }`. `clientCountry` is the country/region code of the requester (e.g. `SG`), `null` if unknown |
| `POST /api/v1/device-login/<userCode>/approve` | Approve (login required; the key is created under this account) | `{ "ok": true }` |
| `POST /api/v1/device-login/<userCode>/deny` | Deny (login required) | `{ "ok": true }` |

- Send the user `verificationUrlComplete` (link with the code) and `userCode` (the confirmation code, e.g. `KMQT-WXBD`); ask them to check the code, then click Authorize. Keep `deviceCode` to yourself; never send it out.
- Poll `token` every `interval` seconds. On `429 slow_down`, add 5 seconds to the interval.
- `403 login_denied`: the user denied. `410 login_expired`: `expiresIn` seconds (10 minutes) passed, or the device code is wrong. Start over in both cases.
- Polling is not idempotent: the key is created on the first poll after approval; later polls return `410 login_consumed` (with `keyName`). So don't auto-retry this request; on a network error wait one `interval` and poll again.
- The key is named `h3 login · <clientName> · MM-DD HH:mm` (Singapore time). It appears in `GET /api/v1/keys` and can be revoked any time.
- Max 10 starts per IP per 10 minutes (IPv6 counted per /64); beyond that `429 too-many-requests`.
- If an account enters a wrong code more than 20 times within 10 minutes, viewing details, approving and denying all return `429 too-many-requests` for that period.
- The code is case-insensitive; spaces and dashes are ignored. Unknown or expired: `404 not-found`. Already approved or denied: `409 login_decided`.

## 3. Uploading assets

Images and audio don't pass through the platform server: request an upload URL, then upload the file directly to it.

### Step 1: `POST /api/v1/uploads` · Request an upload URL

```json
{ "kind": "image", "contentType": "image/png", "size": 482133 }
```

- `kind`: `image`, `audio` or `video` (video is only for motion swap: MP4/MOV, at most 80MB, `duration` 2–180 s).
- `contentType`:
  - images: `image/jpeg`, `image/png`, `image/webp`;
  - audio: `audio/mpeg`, `audio/wav`, `audio/mp4` (.m4a), `audio/aac`, `audio/flac`, `audio/ogg`;
  - video: `video/mp4`, `video/quicktime` (.mov).
  - Aliases are normalized, e.g. `audio/x-m4a`, `audio/mp3`, `image/jpg`.
- `size`: file size in bytes. Images ≤ 10MB, audio ≤ 20MB, video ≤ 50MB.
- `purpose`: optional, `job` (default), `character`, `voice` or `feedback`. `job` is a job asset, deleted automatically when it expires (see "Retention period"). Photos and recordings for the character library must use `"purpose": "character"`; those files are kept until you delete them (section 7). `voice` is for the two recordings used to create My Voice (WAV only: `kind` must be `audio`, `contentType` `audio/wav` or `audio/x-wav`, max 25MB each); they are deleted after use. See "My Voice" in section 8. `feedback` is for screenshots attached to a feedback note (images only, same types and 10MB limit as job images); once attached they are kept with the note, unattached ones are deleted after 6 hours. See section 10.
- `duration`: required for video, not allowed otherwise. Video duration in seconds, 2–15.5. At submission it is used to check the requested length; the machine checks again against the real frame count before generating. Video can only be a job asset, not `"purpose": "character"`.
- `thumb`: optional, images only. `"image/webp"` or `"image/jpeg"` (`true` means JPEG) also returns a `thumbPutUrl` for a small preview (≤ 1MB, short edge about 400px) that you make yourself; upload it like step 2 with that `Content-Type`. The website does this for character photos so long lists load fast. Not needed for jobs; without it the preview URL is just the original.
- `width`, `height`: optional, images only. Width and height in pixels after EXIF rotation; send both together, each 1–20000. Sending them for audio returns 400 invalid-input. If given, the website can warn how much the first/last frame will be cropped; works without them too.

Returns `201`:

```json
{ "id": "6f1c…", "putUrl": "https://h3-files.xxt.ai/…", "contentType": "image/png", "expiresAt": "…" }
```

With `"thumb": true` there is also `"thumbPutUrl"`.

Errors: unsupported type `400 unsupported_type`; too large `413 file_too_large`; file service off `503 files_unavailable`.

### Step 2: `PUT <putUrl>` · Upload the file

```
PUT <putUrl>
Content-Type: <the contentType returned in step 1, exactly>
Content-Length: <bytes>

<file content>
```

- Success: `201`. The upload URL is valid for 30 minutes and can be written once.
- Errors in this step are plain text, not JSON:
  - `411`: no Content-Length;
  - `413`: larger than declared;
  - `415`: wrong Content-Type;
  - `409`: already uploaded, can't overwrite. If you were retrying after a network drop, the previous attempt actually succeeded; treat it as success.

### Asset preview URL: `GET /api/v1/uploads/<id>/file`

Returns `{ "url", "expiresAt" }`, valid 1 hour. Asset not yet used in a job: `409 file_not_ready`. Already deleted: `410 file_expired`. If the image has a small preview there is also `"thumbUrl"` (it may 404 if the preview was never uploaded; fall back to `url`).

To add a preview to an image uploaded without one: `POST /api/v1/uploads/<id>/thumb?contentType=image/webp` (or `image/jpeg`, the default) returns `201` `{ "putUrl", "contentType", "expiresAt" }`. A preview can be written once. Not an image: `400 unsupported_type`; deleted: `410 file_expired`.

An asset id can be reused in other jobs during its retention period; no need to re-upload.

## 4. Submitting a job

### `POST /api/v1/jobs`

Optional header `Idempotency-Key: <1–100 chars>`. Repeated submissions by the same user with the same key create only one job; repeats return the original (status `200`; a new job is `201`). **Send a fresh random key with every submission and reuse the same key when retrying after a network error**, so credits are never held twice. This applies only to job submission (`POST /api/v1/jobs`); song submission does not honor `Idempotency-Key` — see section 8 for how to retry.

Body:

| Field | Type | Description |
|---|---|---|
| `mode` | string, required | `lipsync` lip-sync; `i2v` image-to-video (first frame / first + last frame); `t2v` text-to-video (prompt only); `ref2v` free shot (reference images only, no first frame); `promo` promo video (one character hosts and talks about products; see "Promo video" below); `swap` motion swap (`refVideo` + 1–4 people in `people`, no `prompt`; see motion-swap.md). `GET /api/v1/modes` is authoritative |
| `aspect` | string, required | `9:16` (portrait 768×1344), `16:9` (landscape 1344×768) or `1:1` (square 1024×1024) |
| `seconds` | number, required | Duration, up to two decimals. Range per mode: `lipsync` 2–15, `i2v` 4–15, `t2v` 4–15, `ref2v` 4–15, `promo` 4–15, `swap` 2–60 (`modes` is authoritative) |
| `segments` | 1 or 2 | Number of takes, default 1. 2 = one take each with two different methods (take A, take B) for the user to pick; credits for two. `ref2v`, `promo` and `swap` allow only 1 (`maxSegments` in `modes`) |
| `resolution` | string | Default `768p`. `1080p` renders at 768p, then RTX-upscales to 1080×1920 (landscape 1920×1080, square 1440×1440); double credits |
| `firstFrame` | asset id | First-frame image. Required for `lipsync` and `i2v`; not allowed for `t2v`, `ref2v`, `promo` |
| `lastFrame` | asset id | Last-frame image, optional. `i2v` only |
| `audio` | asset id | Vocal audio. Required for `lipsync`; not allowed in any other mode (including `promo`) |
| `refs` | array | Reference images, max 8 (7 with a last frame), 3–5 recommended. Whenever a person is on screen, always include character or person reference images, lip-sync and first-frame modes included. Not allowed for `t2v` and `promo`; `ref2v` needs at least one (for products, scenes etc. use `other` and say what it is). Each item `{ "asset": "<asset id>", "angle": "<angle>", "note": "<note>" }` |
| `characters` | array | Characters from the character library (section 7). Allowed in `i2v`, `ref2v`, `lipsync`, `promo` (`promo` needs exactly one, the host; `lipsync` needs exactly one, the person singing or speaking, can't send `lines`, and the sung words still go in `dialogue`). Each item `{ "character": "<character id or name>", "wardrobe": false, "angles": ["front", "side"], "position": "the young woman on the left" }`; order = character order; each character at most once. `angles` is optional: if given, only this character's photos at these angles are used (pick by the shot's camera angle, see "Picking photos" below); each angle once, `other` not allowed. If the character has no photo at a listed angle: `400 invalid-input` (`path` = `characters[i].angles[j]`). `wardrobe: true` also references the clothes; `ref2v` only (in `i2v` and `lipsync` clothes follow the first frame). `position` (≤ 60 characters, `i2v` with `promptMode: describe` only, otherwise `400 invalid-input`, `path` = `characters[i].position`): who this is and where they stand in the first frame, e.g. `左边穿米色毛衣的年轻女人`; when three or more characters all have one and there are `lines`, the platform names each speaker by position (prompting.md section 1). Characters + `refs` ≤ 8. **When characters are used, person photos come from the characters; `refs` hold only scenes, products and props**: every `angle` must be `other` with a `note` saying what it is; anything else is `400 invalid-input` (`path` = `refs[i].angle`) |
| `lines` | array | Lines, one per utterance; requires characters in `characters`. Max 10, in speaking order. Each item `{ "character": "<as written in characters>", "text": "<line>", "action": "<action and tone while saying it>" }`: `text` required, 1–200 chars; `action` optional, max 200 chars; neither may contain `<`, `>` or newlines. At most 3 speaking characters per shot (`i2v` too). Can't be combined with `dialogue`; in `describe` with characters you can't use `dialogue` (use `lines`); in `raw` you can't use `lines` (write lines into the prompt). With characters and no `lines`, the platform extracts lines from quotes in `prompt` (see "Lines in the prompt" below); **better to split them yourself and send `lines`** |
| `prompt` | string, required | `describe` 1–4000 chars, `raw` 1–8000 chars. Optional for `promo` (platform uses a default; see "Promo video"). In `describe` with characters and no `lines`, quoted text becomes lines; for promo without `sellingPoints`, selling points and wear are extracted too (see "Lines in the prompt"). How to write: [prompting guide](https://minimax.xxt.ai/references/prompting.md) |
| `promptMode` | string | `describe` (default): a scene description, Chinese allowed; the platform translates it to English and wraps it in H3 format. `raw`: your own complete H3-format prompt, used as is; must contain `integrated_multimodal_description:` |
| `dialogue` | string | The sung or spoken words, inserted verbatim (not translated); for lip-sync it must match the vocal word for word. Max 500 chars, no `<`, `>` or newlines. `describe` only; the platform writes it as `(S1)` plus `<d>[language] text</d>`, language detected from the text (Chinese, Japanese, Korean, otherwise English) |
| `sound` | string | Ambient sound description, max 500 chars, no `<`, `>` or newlines. `describe` only |
| `seed` | integer | Random seed, 1–2147483647. Random if omitted |
| `products` | array | `promo` only, required. 1–4 products; see "Promo video" |
| `scene` | object | `promo` only, optional. Scene image `{ "asset": "<asset id>", "name": "<scene name>" }`, `name` 1–40 chars |
| `sellingPoints` | string | `promo` only, optional. Selling points, max 300 chars; the platform writes the host's pitch from them. Can't be combined with `lines`. If neither is sent and `prompt` is given, the platform extracts selling points from `prompt` |
| `refVideo` | asset id | `swap` only, required: the motion video (`kind: video`, 2–180 s; the output is at most 60 s for now). `seconds` (counted from the start) can't exceed its length (within 0.25 s is accepted), otherwise `400 ref_video_too_short` |
| `people` | array | `swap` only, required: the people to swap in, 1–4, in order (`<Picture>` numbering follows this order): each `{ "photos": ["<asset id>", …] }` (1–4 photos of the same person: front face, three-quarter face, full body look most alike), `{ "photo": "<asset id>" }` or `{ "character": "<id or name>" }` (the platform uses its `full_front`, `front` and three-quarter photos), with an optional `role` (who they replace, ≤ 60 chars, no `<`, `>` or newlines). **All people's photos together at most 9** (uploaded photos over 9 → `400 invalid-input`; characters' photos are trimmed to fit, last angles first). One person: the role decides which shots have them, and in those shots the biggest person is redrawn. 2–4 people: everyone in each shot is tracked and matched to a person by the vision model (give each a role); matched people are redrawn in one pass, everyone else is kept. A full-body photo works best: the outfit comes from it |
| `noLipSync` | boolean | `swap` only. Default `false` (mouths follow singing or speech in the original audio); `true` when nobody in the video sings or talks |
| `keepRefAudio`, `feedRefAudio` | boolean | Old motion-swap fields, no longer accepted (`swap` always uses the original audio) |
| `part` | object | `promo` only, optional. Which part this is when long content is split: `{ "index": 2, "total": 6 }`, 1-based, `index` ≤ `total`, `total` ≤ 20 |

When characters are used, the platform does this:

- **Picking photos**: max 8 photos total, 7 with a last frame (the model takes at most 9 images; first and last frame use one each; `refs.maxWithLastFrame` in `GET /api/v1/modes`). `refs` take their slots first; characters get the rest. A character with `angles` is picked only from photos at those angles (front photo still first, the rest in `angles` order). Recommended: always include `front`; add `side` or a three-quarter angle for profile shots, `full_back` for shots from behind. Each character first gets one front (`front`) photo, or its first photo if it has none. Then characters take turns in character order, one more photo each in their photo order, until the slots are full or photos run out (same for `ref2v`, `i2v`, `lipsync`). E.g. 2 characters plus 2 scene/prop images: 3 photos per character. 1 character and no `refs`: all its photos are used.
- **Image numbering**: character photos in character order, each character in picked order; `refs` follow all character photos. `ref2v` starts at `<Picture 1>`. In `i2v` and `lipsync` the first frame is `<Picture 1>`, character photos start at 2, and the last frame comes after all other images.
- **Scene, product and prop images**: in `ref2v` each `refs` image is its own subject, numbered after the characters (from `<Subject number-of-characters+1>`). In the prompt just mention what its `note` says, e.g. the character sits in the "café" holding the "red cup".
- **Voice numbering**: `<Audio n>` is the n-th speaking character, in order of first line. In `raw` there are no `lines`; numbering follows the characters with voices in `characters` order, max 3. Every character with lines must have a voice, otherwise `409 character_voice_not_ready`.
- **Names and descriptions**: write character names directly in `prompt` and `action`; the platform replaces them with `<Subject k>`. Each character's description (appearance) and each speaker's voice description go into the prompt. `action` is translated and placed before its line.
- Character photos were checked when the character was created and aren't checked again per job; first and last frames are still checked.

### Lines in the prompt

In `describe` with characters and no `lines`, you can write lines directly in `prompt`; the platform splits them with an LLM (the same model as translation; no extra credits; submission takes a few seconds longer):

- **When it splits**: `i2v` and `ref2v` only if `prompt` contains quotes; without quotes the whole text is a scene description as before. `promo` when no `sellingPoints` and `prompt` is non-empty. Never in `raw`, or when `lines` (for promo: `sellingPoints`) is sent.
- **Only quoted text counts**: text inside 「」 “” "" 『』 is a line, kept verbatim and not translated; everything outside quotes is scene description. Name the speaker before the quote, e.g. `小美笑着对小王说：「好久不见！」小王点点头：「是啊，三年了。」` (Xiaomei smiles at Xiaowang: "Long time no see!" Xiaowang nods: "Yeah, three years."). Speakers must be characters selected for this shot (in promo, always the host). For on-screen text (signs, captions, slogans), put it after "写着" / "reading" in quotes (`a red neon sign reading "营业中"`); the platform recognizes it as on-screen text, not a line, and keeps it with its quotes in the scene description (even with a single character it isn't attributed to them).
- **After splitting, the `lines` rules apply**: speaker and action come from the prompt; voices are numbered by speaking order; speakers need a voice (`409 character_voice_not_ready`); max 10 lines, ≤ 200 chars each, ≤ 3 speakers, promo lines ≤ 30 total units; violations are `400 invalid-input` with `path` = `prompt`. An unclear speaker is also `400 invalid-input` (`path` = `prompt`, `message` quotes the line); add the character name before the quote and resubmit. The model decides which quotes are lines: quotes it treats as on-screen text, song titles, brands or emphasis stay in the description without a voice. After submitting, check `lines` in the job view for anything missed.
- **Promo video** also extracts selling points and whether each product is worn: if lines were extracted they are used; otherwise the pitch is written from the extracted selling points (same as sending `sellingPoints`; only the first 300 chars of extracted selling points are used). A product's own `wear` wins; products without one follow the prompt ("让她穿上这条连衣裙" / "have her wear this dress").
- **Moderation**: the original text is checked first; failure is `400 content_blocked` (`field` = `prompt`). The extracted scene description, lines, selling points and the pitch written from them are checked as usual; failures are also reported as `field: "prompt"` (the prompt is what you need to change).
- **Seeing the result**: in the job view `prompt` is your original text and `lines` holds the extracted lines, each with `"fromPrompt": true`. Extracted selling points and wear are only used to write the pitch and build the prompt; they are not stored on the job: `sellingPoints` and `products[].wear` show what you sent (`null` / absent if not sent). Generating again re-splits the original prompt.
- **Split failure**: model call failed (each call waits at most 25 s; timeout counts as failure) or two attempts gave no valid result: `503 moderation_unavailable`. No model configured: `503 translation_unavailable`. Both are retried during review for up to 30 minutes, then the job fails with that code in `rejection` and is fully refunded.
- **Better to split yourself**: if the agent sends `lines` (for promo also `sellingPoints` and product `wear`), the platform doesn't split. More accurate, one fewer model call, submission a few seconds faster.

### Promo video (`promo`)

One character hosts, talking to camera about one or a few products. One take only, 4–15 s per job.

- **Host**: `characters` must contain exactly one character; `wardrobe` not allowed (to have the host wear a product, set the product's `wear`). The character must have a voice, otherwise `409 character_voice_not_ready`. The host is the only speaker.
- **`offCamera`** (boolean, default `false`): the host is not shown. Host photos are not sent (all image slots go to products and the scene); the clip is a first-person view of hands holding the products, and the host's voice narrates off screen. `characters` still needs the host (for the voice). Products can't have `wear` (`400 invalid-input`, `path` = `products[i].wear`); `wear` extracted from `prompt` is ignored. Platform-written lines use hand actions. Only `promo` accepts it. The job view has `offCamera`.
- **Not allowed**: `firstFrame`, `lastFrame`, `audio`, `refs`, `dialogue`, and two takes (`segments` must be 1); otherwise `400 invalid-input`.
- **`products`**: 1–4, each `{ "assets": ["<asset id>", …], "name": "<name>", "note": "<extra info>", "wear": "outfit" }`:
  - `assets`: 1–3 images of this product (different angles); max 6 images across all products;
  - `name`: 1–40 chars; `note`: optional, max 100 chars;
  - `wear`: optional. `outfit` = clothes, shoes, bags, worn by the host, medium shot by default. `accessory` = jewelry, watches, earrings, glasses, worn by the host, medium close-up by default to show detail. Omitted = the host holds it up to show it. When the platform extracts selling points from `prompt` (see "Lines in the prompt"), products without `wear` follow the prompt;
  - name, note, selling points and scene name can't contain `<`, `>` or newlines.
- **Lines**: pick one of three ways; `lines` and `sellingPoints` can't both be sent:
  - write `lines` yourself (see table above; all spoken by the host);
  - send `sellingPoints`, or nothing at all (no `prompt` either): the platform writes 1–3 pitch lines from the products and selling points, no extra credits. With `part`: part 1 greets and introduces the products, middle parts cover selling points, the last part wraps up and pushes the sale. Platform-written lines carry `"generated": true` in the job view;
  - put selling points and the lines to say into `prompt` (lines in quotes); the platform extracts both. See "Lines in the prompt".
- **Lines ≤ 30 units**: more than that in total produces subtitles. Each Chinese character counts 1, each English word (run of letters or digits) counts 5/3 (30 ≈ 18 English words); punctuation and spaces don't count. Your own `lines` over the limit: `400 invalid-input`; lines extracted from `prompt` over the limit too (`path` = `prompt`). Platform-written lines stay within budget: `min(30, floor(seconds × 3.5))`. To say more, split into several parts.
- **Prompt**: `prompt` is optional; if given it is the scene description (action, shot size, setting), written as in section 4 (without `lines` / `sellingPoints` the platform first extracts selling points and lines; the rest is the scene description). Write character, product and scene names directly; the platform replaces them with the right tags.
- **Long videos in parts**: split long content into 8–10 s parts; each part uses the same host, products, scene and description, differing only in `part`. Download each part and join locally (the platform doesn't join); add subtitles and music locally too.
- **Image numbering and count**: max 8 images total. Product and scene images take slots first; host photos get the rest, at least 1 (front first; none with `offCamera`). Order: host photos, product images (in `products` order, each in `assets` order), scene image.
- Priced like other modes, by seconds.

Example: one product plus a scene, platform writes the pitch.

```json
{
  "mode": "promo",
  "aspect": "9:16",
  "seconds": 8,
  "characters": [{ "character": "小满" }],
  "products": [
    { "assets": ["d4e5…", "d4e6…"], "name": "保温杯", "note": "白色，杯身有蓝色图案" }
  ],
  "scene": { "asset": "e7f8…", "name": "厨房" },
  "sellingPoints": "保温 12 小时，一键开盖，容量 500 毫升",
  "part": { "index": 1, "total": 3 }
}
```

(Product "insulated cup", white with a blue pattern; scene "kitchen"; selling points "keeps warm 12 hours, one-touch lid, 500 ml".)

Reference image `angle`:

| Value | Meaning |
|---|---|
| `front` | Face, frontal |
| `three_quarter_left` | Face, left three-quarter |
| `three_quarter_right` | Face, right three-quarter |
| `side` | Face, full profile |
| `full_front` | Full body, front |
| `full_back` | Full body, back |
| `other` | Other. `note` required (max 80 chars, no `<`, `>` or newlines) saying what the image is |

Example: lip-sync, first frame plus two reference images, one take.

```json
{
  "mode": "lipsync",
  "aspect": "9:16",
  "seconds": 8.4,
  "firstFrame": "6f1c…",
  "audio": "0b9e…",
  "refs": [
    { "asset": "a1d2…", "angle": "front" },
    { "asset": "c3f4…", "angle": "three_quarter_left" }
  ],
  "prompt": "中景，女孩站在傍晚的河边，看着镜头唱歌，右手轻轻按在胸口，晚风吹动头发。镜头固定。",
  "dialogue": "晚风吹过河岸，我还在等你"
}
```

(Prompt: "Medium shot, a girl stands by a river at dusk, singing to camera, right hand lightly on her chest, evening breeze in her hair. Static camera." Dialogue: "The evening wind crosses the riverbank, I'm still waiting for you".)

Returns the job view (section 5).

**Checks run in two stages**; a failed step stops the rest, and **nothing that fails is charged**.

- **Right away** (steps 1–3, plus characters: not found, `409 character_voice_not_ready` when the speakers are already known): the request fails with that error and nothing is created. If they pass, the job is created with `status` = `reviewing`, credits are held, and you get `201` with that job.
- **Then in the background** (steps 4–6): the platform reviews jobs at its own pace (a few model calls at a time platform-wide, taking turns between users), so a whole batch can be submitted at once or in parallel. Passed: the job becomes `queued`. Failed: the job becomes `failed`, all credits are refunded, and `rejection` holds the error a rejected submission would have returned, e.g. `{ "status": 400, "error": "content_blocked", "message": "…", "target": "prompt", "field": "prompt" }`. If the model is temporarily unreachable, review retries automatically; after 30 minutes the job fails with `rejection.error` = `moderation_unavailable`. Poll `GET /api/v1/jobs/<id>` every few seconds until `status` leaves `reviewing`. `h3 submit` does this and reports a rejection like an immediate error, plus `jobId`.

1. Parameters: `400 invalid-input`; `issues` says which field.
2. Assets: not yours, missing or deleted `404 asset_not_found`; not uploaded or wrong file `400 upload_missing`.
3. Balance: `402 insufficient_credits`, with `balance` and `required`.
4. Splitting lines from the prompt (only when it applies; see "Lines in the prompt"): original text blocked `400 content_blocked` (`field` = `prompt`); unclear speaker or limits exceeded `400 invalid-input` (`path` = `prompt`); split failed `503 moderation_unavailable`; no model configured `503 translation_unavailable`.
5. Content check: `400 content_blocked`, with `target` and `field` saying what failed (the platform doesn't produce pornography, nudity showing nipples, or content sexualizing minors):
   - `"prompt"`: text failed. `field` is `prompt` (scene description; lines and selling points the platform extracted from the prompt are also reported here), `dialogue` (dialogue), `sound` (ambient sound), `refNote` (reference image note, plus `ref`: reference image index from 0), `line` (a line or its `action`, plus `line`: line index from 0), `character` (a character's description, or a speaker's voice description, plus `character`: character index from 0). Promo video adds `product` (product name or note, plus `product`: product index from 0), `scene` (scene name), `sellingPoints` (selling points, or the pitch the platform wrote from them). If the English translation of Chinese text fails, the original field is reported. If several fields fail, the first one is reported.
   - `"image"`: an image failed. `field` is `firstFrame`, `ref` or `lastFrame`, plus `index` (image index from 0, in order first frame, references, last frame). When `field` is `ref`, also `ref` (reference image index from 0; free shot has no first frame, so `index` equals it). Promo product images: `field` = `product`, with `product` (product index) and `index` (image index within that product), both from 0. Scene image: `field` = `scene`. Motion swap photos: `field` = `person`, with `index` (person index from 0).
   - Motion swap's `role` is checked against a word list only: `target` = `"prompt"`, `field` = `role`, `index` (person index from 0). The motion video is checked by the machine before rendering; a failure ends the take with segment error `content_blocked` and a full refund.
6. Translation: Chinese prompts are translated; if translation is unavailable, `503 translation_unavailable` (an English prompt still goes through; promo without lines uses this model to write the pitch, and splitting lines from the prompt uses it too; sending your own `lines` avoids it). Content check unavailable: `503 moderation_unavailable`.

When review passes, the job is queued.

### Credits

- Priced by Singapore time: daytime 10:00–22:00: 9 credits per second; night 22:00–10:00 next day: 6 credits per second. The rate is fixed at the moment the submission is received and doesn't change after the hold. Current rate: `creditsPerSecond` in `GET /api/v1/modes`.
- Hold per take = duration rounded up to whole seconds × rate; ×2 for two takes. E.g. 8.4 s, one take: 108 credits by day, 54 at night.
- `1080p` ×2; motion swap (`swap`) ×3; both = ×6. E.g. a 10 s motion swap at 768p by day holds 360 credits.
- Each take settles separately:
  - succeeded and passed the QC check: full charge;
  - succeeded but failed the QC check (still delivered): half, rounded down;
  - failed or canceled: full refund.

### `POST /api/v1/jobs/estimate` · Price check before submitting

Body: `{ "mode", "seconds", "segments"?: 1|2, "resolution"?: "768p"|"1080p" }`, same rules as submitting (duration range, one take for `ref2v`/`promo`/`swap`; `swap` is priced ×3, shown as `factors.refVideo`). Creates nothing, holds nothing, checks no assets or prompt (moderation and translation happen on submit).

```json
{
  "credits": 288, "perSegment": 144, "billedSeconds": 6, "creditsPerSecond": 12,
  "factors": { "resolution": 2, "refVideo": 1, "segments": 2 },
  "pricing": { "timezone": "Asia/Singapore", "period": "day", "until": "2026-10-25T14:00:00.000Z" },
  "balance": 500, "enough": true, "waitSeconds": 120, "estimateSeconds": 384,
  "note": "只算价格，不建任务、不冻结积分；……"
}
```

`credits` is exactly what submitting now would hold; it changes after `pricing.until`. `enough: false` means submitting now returns `insufficient_credits`. `waitSeconds` (queue, `null` if no estimate) plus `estimateSeconds` is roughly when it finishes.

## 5. Job view

Returned by get, submit and cancel:

```json
{
  "id": "11111111-2222-3333-4444-555555555555",
  "mode": "lipsync",
  "aspect": "9:16",
  "resolution": "768p",
  "seconds": 8.4,
  "segments": 1,
  "status": "running",
  "exempt": false,
  "prompt": "用户提交的提示词原文",
  "promptFinal": "For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced.\n\nintegrated_multimodal_description: [Shot 1] Live-action, cinematic, …",
  "createdAt": "…",
  "finishedAt": null,
  "credits": { "held": 108, "charged": 0 },
  "estimateSeconds": 269,
  "retentionNotice": "成片、歌曲和上传的素材在任务结束后保存 8 小时，之后自动删除，请及时下载。",
  "webUrl": "https://minimax.xxt.ai/studio?job=3f2a…",
  "characters": [],
  "lines": null,
  "products": [],
  "scene": null,
  "sellingPoints": null,
  "inputs": { "firstFrame": "6f1c…", "lastFrame": null, "audio": "0b9e…", "refs": [{ "asset": "a1d2…", "angle": "front" }], "refVideo": null },
  "keepRefAudio": false,
  "feedRefAudio": false,
  "segmentsDetail": [
    {
      "label": "A",
      "status": "running",
      "queueAhead": null,
      "waitSeconds": null,
      "progress": { "phase": "generating", "percent": 40 },
      "qc": null,
      "charged": null,
      "expiresAt": null,
      "fileAvailable": false,
      "cover": null,
      "error": null
    }
  ]
}
```

(`prompt` here is "the user's original prompt". `retentionNotice` is the Chinese retention notice, i.e. "Videos, songs and uploaded files are kept for 8 hours after the job ends, then deleted automatically. Please download them in time.")

- `status` (whole job): `reviewing` content check and prompt preparation in progress (section 4), `queued` queued, `running` generating, `done` finished with at least one take succeeded, `failed` all failed, `canceled` canceled.
- `characters`: characters used, `[{ "id", "name", "wardrobe", "angles"?, "position"? }]`, in submission order; empty array if none. `lines`: `[{ "character": "<character id>", "text", "action"? }]`, `null` if none. Lines the platform extracted from quotes in the prompt (no `lines` sent) carry `"fromPrompt": true`. For promo with no lines, this holds the pitch the platform wrote from the selling points, each with `"generated": true`. `inputs.refs` contains only the plain reference images submitted, not character photos, product images or the scene image.
- `products`: promo products `[{ "assets": ["<asset id>"…], "name", "note"?, "wear"? }]`, in submission order. `scene`: `{ "asset", "name" }` or `null`. `sellingPoints`: the submitted selling points or `null`. These are what you sent; selling points and wear extracted from the prompt are not shown here. In other modes they are `[]`, `null`, `null`.
- `inputs.refVideo`: the motion swap's motion video asset id, `null` if none. `people`: for `swap`, `[{ "photo", "photos", "character"?, "role"? }]` (the photos actually used; `photo` is the first), empty for other modes; `noLipSync`: whether lip-sync was off. `keepRefAudio`, `feedRefAudio`: from old motion-swap jobs.
- `prompt` is the submitted text. `promptFinal` is the full prompt actually sent to the model (Chinese translated to English, wrapped in H3 format); for `raw` it equals the original. Empty while `reviewing`.
- `rejection`: why review rejected the job, `null` otherwise. Same shape as an immediate error response plus `status` (the HTTP status it would have had): `{ "status", "error", "message", … }` with the same extra fields (`target`, `field`, `character`, `issues`…). The takes are `failed` with `error` = the same code, and credits are refunded.
- `estimateSeconds`: roughly how long generation takes once started (queue excluded); an estimate only.
- `webUrl`: this job on the website; opening it (after signing in) shows the job's progress, player and download buttons. Give it to the user.
- `credits`: `held` still on hold, `charged` already charged.
- `segmentsDetail`: one entry per take; `label` is `A` or `B`.
  - `status`: `reviewing`, `queued`, `running`, `succeeded`, `failed`, `canceled`.
  - `queueAhead`: takes queued ahead of this one; `null` when not queued.
  - `waitSeconds`: about how many seconds until it starts, erring on the long side; `null` if no estimate yet (still queued).
  - `progress`: only while generating. `phase` is `downloading` (fetching assets), `generating`, `uploading` (uploading the output); `percent` is generation progress, may be `null`.
  - `qc`: QC check result `{ "passed": true/false, "reasons": ["…"] }`. A take that fails is still delivered; `reasons` explains why, and only half the credits are charged for it. `null` = no QC report.
  - `charged`: credits actually charged for this take; `null` until settled.
  - `expiresAt`: when the output is deleted; `null` while the job is still running (the 8 hours haven't started).
  - `fileAvailable`: whether the output can be fetched now.
  - `cover`: URL of this take's cover image (first frame scaled to 480 px wide WebP, tens of KB), valid 1–2 hours; fetch the job again for a fresh one after expiry. `null` once the output is gone or if this take has no cover. Preview only; use the file endpoint below for the video.
  - `error`: failure code, `null` if not failed:
    - `machine_lost`: the machine dropped out repeatedly;
    - `timeout`: didn't finish in time;
    - `input_missing`: an asset was deleted;
    - `content_blocked`: failed the content check (in review, or an image used by the job failed it later);
    - any other code from `rejection` when review rejected the job;
    - `generation_failed`: generation error.

    Credits for failed takes are always refunded.

## 6. Query, cancel, download

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/jobs?active=1&limit=20&before=<next>` | My jobs, newest first. `active=1` lists only unfinished jobs and those finished less than 8 hours ago; omit it for all. `limit` default 20, max 100. Returns `{ "items": [job view], "next" }` |
| `GET /api/v1/jobs/<id>` | Get one job. Missing or not yours: `404 job_not_found` |
| `POST /api/v1/jobs/<id>/cancel` | Cancel; returns the job view. Queued and running takes stop and all held credits are refunded. Already finished: `409 job_not_cancelable` |
| `GET /api/v1/jobs/<id>/segments/<A or B>/file?disposition=attachment` | URL for this take's video `{ "url", "expiresAt" }`, valid 1 hour. `disposition=attachment` downloads with a filename; `inline` (default) plays in the browser. Not ready: `409 file_not_ready`. Past retention: `410 file_expired` |

`GET` the `url` directly to get the mp4; no key needed. Output is H.264 at 24 fps. Motion swap (`swap`) is **60 fps**: after generation the GPU machine interpolates frames with RIFE before delivery (with 1080P: interpolate first, then upscale), no extra credits. The file runs slightly longer than `seconds` (e.g. 10 s comes out about 10.13 s).

**Polling**: after submitting, `GET /api/v1/jobs/<id>` every few seconds while `status` is `reviewing`, then every 10–30 s until it is `done`, `failed` or `canceled`. A take usually takes a few minutes, longer when queued; see `waitSeconds`.

### Info endpoints

- `GET /api/v1/modes`: per mode: duration range; whether first frame, vocal, last frame and reference images are needed (`firstFrame`, `audio`, `lastFrame`, `refImages`: `required` / `optional` / `forbidden`); aspect ratios and sizes; reference image count and angles; upload limits; prices; retention notice. **Its numbers are authoritative**; more modes will be added. Pricing fields:
  - `creditsPerSecond`: the per-second rate for a submission right now;
  - `pricing`: `{ "timezone": "Asia/Singapore", "day": { "from": "10:00", "to": "22:00", "creditsPerSecond": 12 }, "night": { "from": "22:00", "to": "10:00", "creditsPerSecond": 6 }, "period": "day", "until": "<when the current rate ends>" }`;
  - `resolutions`: price factor and output sizes per resolution, `[{ "resolution": "768p", "priceFactor": 1, "sizes": [...] }, { "resolution": "1080p", "priceFactor": 2, "sizes": [...] }]`.
- `GET /api/v1/queue`: `{ "pending": 5, "waitSeconds": 600 }`. `pending` is the total number of queued and running takes platform-wide. `waitSeconds` is about how many seconds until a take submitted now would start (erring on the long side), `null` if no estimate yet. The number of machines isn't disclosed.

## 7. Character library

When the same person appears in several shots, create a character: a few photos plus an optional recording of the person's own voice. A character's photos and voice are kept until you delete the character, the photo or the voice (after deletion, files no job is using are deleted within 8 hours; files still used by a job are deleted within 8 hours after that job ends). This is separate from the job assets and outputs covered by "Retention period" above.

Use characters in `characters` and `lines` of `POST /api/v1/jobs`; rules in section 4. **A character that speaks lines needs a voice**: upload a recording when creating it, pick one of the ready-made voice templates (see "Voice templates" below), or have the platform generate one from a voice description (see "Audition" below). A character without a voice can appear on screen but can't speak.

### Upload first, then create the character

Photos and recordings use the same two-step upload as section 3, but step 1 must include `"purpose": "character"`:

```json
{ "kind": "image", "contentType": "image/png", "size": 482133, "purpose": "character" }
```

- Assets uploaded with the default `purpose: "job"` can't go into a character: `400 wrong_purpose` (job assets are deleted automatically within a day).
- An asset can be used in only one place: not in two characters, nor in two slots of the same character: `409 asset_in_use`. Upload another copy to use it elsewhere.
- Photos: JPG, PNG, WebP, ≤ 10MB. They are content-checked when creating the character and when adding photos; failures are `400 content_blocked` with `field` = `photos` and `index` = position in the request (from 0).
- Recordings: MP3, WAV, M4A, AAC, FLAC, OGG, ≤ 20MB. **3–15 s recommended; jobs use only the first 15 s.** The server doesn't check recording length.
- Creating a character and adding photos require the content check; if it is unavailable you get `503 moderation_unavailable`, nothing is created; try again later.

### Character shape (`CharacterView`)

```json
{
  "id": "0b8e…",
  "name": "小雨",
  "description": "二十多岁的女孩，齐肩短发，爱笑",
  "voiceDescription": "二十五岁左右的女声，音色温暖、略带气声，语速中等，语气轻松",
  "language": "Chinese",
  "photos": [{ "id": "7c21…", "asset": "6f1c…", "angle": "front" }],
  "voice": { "status": "ready", "source": "upload", "asset": "a93d…", "template": null },
  "createdAt": "…",
  "updatedAt": "…"
}
```

(Description: "a girl in her twenties, shoulder-length hair, smiles a lot". Voice description: "female voice around 25, warm, slightly breathy timbre, medium pace, relaxed tone".)

- `language`: the language the character speaks: `Chinese`, `English`, `Japanese`, `Korean`; default `Chinese`.
- `photos[].angle`: `front`, `three_quarter_left`, `three_quarter_right`, `side`, `full_front`, `full_back` (no `other`). `photos[].id` is the photo's id within the character, used to delete or reorder it; `photos` is in the character's photo order (new photos go last; reorder with `PUT …/photos/order`); `asset` is the asset id.
- `voice.status`: `none` no voice; `ready` voice available. `voice.source`: `upload` (your own recording), `audition` (made by audition or voice preview), `template` (a voice template), or `null` with no voice. `voice.asset`: the voice's asset id, `null` if none. `voice.template`: the template id when `source` is `template`, else `null`.
- Name, description and voice description go into prompts and can't contain `<`, `>` or newlines.

### Endpoints

All endpoints see only your own characters; others', missing and deleted ones all return `404 character_not_found`, without revealing existence.

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/characters` | My characters (not deleted), array of `CharacterView` |
| `POST /api/v1/characters` | Create a character; returns `201` and `CharacterView` |
| `GET /api/v1/characters/<id>` | Get one character |
| `PATCH /api/v1/characters/<id>` | Change name, description, voice description, language; at least one; an empty string clears description or voice description |
| `DELETE /api/v1/characters/<id>` | Delete the character (photos and voice too); returns `{ "ok": true }` |
| `POST /api/v1/characters/<id>/photos` | Add a photo, body `{ "asset", "angle" }`; returns `201` and `CharacterView` |
| `DELETE /api/v1/characters/<id>/photos/<photoId>` | Delete a photo; returns `CharacterView`. The last photo can't be deleted |
| `PUT /api/v1/characters/<id>/photos/order` | Reorder photos, body `{ "photos": ["<photoId>", …] }` (1–8 distinct photo ids of this character): listed photos move to the front in that order, the rest keep their order after them; returns `CharacterView`. The first photo is the cover. An id not on this character: `404 character_not_found` |
| `PUT /api/v1/characters/<id>/voice` | Set or replace the voice, body `{ "asset": "<recording or voice preview asset id>", "voiceDescription": "…" }` (`voiceDescription` optional; if present it also updates the character's voice description, empty string clears it); returns `CharacterView` |
| `PUT /api/v1/characters/<id>/voice/template` | Switch to a voice template, body `{ "template": "<template id>" }`; returns `CharacterView`. Free, no audition used. See "Voice templates" below |
| `DELETE /api/v1/characters/<id>/voice` | Remove the voice; returns `CharacterView` |
| `GET /api/v1/characters/<id>/photos/<photoId>/file` | URL of one photo `{ "url", "expiresAt" }`, valid 1 hour; plus `"thumbUrl"` when the photo has a small preview (see "Asset preview URL") |
| `GET /api/v1/characters/<id>/voice/file` | URL of the voice `{ "url", "expiresAt" }`, valid 1 hour, for listening. No voice: `404 character_not_found` |
| `POST /api/v1/characters/voice-previews` | Listen before committing: generate a voice from a voice description without attaching it to a character; returns `201` and `{ "asset", "url", "expiresAt" }`. See "Voice preview" below |
| `POST /api/v1/characters/<id>/voice/audition` | Audition: generate a voice from the voice description and make it the character's voice right away; returns `CharacterView`. See "Audition" below |
| `GET /api/v1/characters/audition-quota` | Today's audition count `{ "used", "limit", "resetsAt" }` |
| `GET /api/v1/characters/voice-templates` | The 14 voice templates, see "Voice templates" below |

Body of `POST /api/v1/characters`:

```json
{
  "name": "小雨",
  "description": "二十多岁的女孩，齐肩短发，爱笑",
  "voiceDescription": "二十五岁左右的女声，音色温暖、略带气声，语速中等，语气轻松",
  "language": "Chinese",
  "photos": [{ "asset": "6f1c…", "angle": "front" }, { "asset": "b7e0…", "angle": "three_quarter_left" }],
  "voice": "a93d…"
}
```

- `name`: required, 1–40 chars; must be unique among your undeleted characters (`409 name_taken`).
- `photos`: required, 1–8, each `{ asset, angle }`.
- `description` (max 300 chars), `voiceDescription` (max 200 chars), `language`, `voice`, `voiceTemplate`: optional. `voice` is a recording's asset id, or an asset id made by "Voice preview" (then put the voice description used to make it in `voiceDescription`). `voiceTemplate` is a voice template id (see "Voice templates" below); `voice` and `voiceTemplate` are mutually exclusive (`400 invalid-input`).
- If neither `voice` nor `voiceTemplate` is sent, the platform generates a voice from the voice description (or the description if there is none), same rules as "Audition" below. If generation fails the character is still created, just without a voice; you can audition later.
- Max 50 undeleted characters per account (`409 character_limit`). Deleted ones don't count, and their names can be reused.
- Max 8 photos per character; beyond that `400 too_many_photos`.
- Photo or recording asset missing, not yours or deleted: `404 asset_not_found`; not uploaded or wrong file: `400 upload_missing`.

### Voice templates

This is what 「选声音模板」 on the website does: 14 voices made in advance, seven groups (`child`, `teen`, `young`, `middle`, `elder`, `persona`, `host`) of one woman and one man, spread far apart. All read a Chinese sentence.

`GET /api/v1/characters/voice-templates` returns an array in display order:

```json
[{ "id": "ceo", "name": "霸总", "nameEn": "Domineering CEO", "gender": "male", "group": "persona", "age": 35,
   "description": "三十五岁的霸道总裁，声音冷峻、低沉、干脆，说话简短有力，不容置疑",
   "previewUrl": "https://minimax.xxt.ai/voice-templates/ceo-v1.flac" }]
```

- `previewUrl` is a public ~10 s FLAC (no login, doesn't expire) for listening.
- Ids: `girl`, `boy`, `teen-girl`, `student`, `young-woman`, `young-man`, `mom`, `uncle`, `grandma`, `grandpa`, `queen`, `ceo`, `host`, `narrator`.
- Use one with `voiceTemplate` when creating a character, or `PUT /api/v1/characters/<id>/voice/template` with `{ "template": "<id>" }`. The platform copies the template to the character as its own voice (`voice.source` = `template`, `voice.template` = the id) and sets the character's voice description to the template's `description`. Free, no credits, doesn't count toward the audition quota, takes a second or two. Choosing the template the character already has returns it unchanged.
- Unknown id: `400 invalid-input`. File service trouble: `503 voice_template_unavailable` (the character keeps its voice; when creating, the character is still created, without a voice).

### Voice preview (listen first, then decide)

This is what "Custom voice" (「自定义声音」) on the website does: generate a voice from a voice description for the user to hear, and only create the character or replace the voice once they're happy.

`POST /api/v1/characters/voice-previews`, body `{ "voiceDescription": "二十五岁左右的女声，音色温暖、略带气声，语速中等，语气轻松", "language": "Chinese" }`: `voiceDescription` required, 1–200 chars; `language` optional, the language of the sentence read in the preview, default `Chinese`.

- Success `201`, returns `{ "asset": "<asset id>", "url": "<preview URL>", "expiresAt": "…" }`. The URL is valid 1 hour; `GET` it for a ~10 s WAV.
- If happy: pass `asset` as `voice` when creating a character (with the voice description used to make it in `voiceDescription`), or `PUT /api/v1/characters/<id>/voice` with `{ "asset", "voiceDescription" }` to replace an existing character's voice. A voice attached this way has `voice.source` = `audition`.
- If not: change the voice description and generate again. Unused previews need no cleanup; the platform deletes them after 6 hours, so use one within 6 hours of making it. A preview can be attached to only one character; attaching it elsewhere is `409 asset_in_use`.
- Quota, content check and error codes are the same as "Audition" below (it counts toward the daily audition quota). One preview at a time per account, otherwise `409 audition_in_progress`.

### Audition (generate a voice from a description)

With no recording of the person, let the platform generate a voice from text. Free, no credits. The result is a ~10 s WAV; get a URL with `GET /api/v1/characters/<id>/voice/file` to listen.

`POST /api/v1/characters/<id>/voice/audition`, body empty or `{ "voiceDescription": "…" }` (max 200 chars, used for this audition; if omitted, the character's existing voice description is used, or its description if none). An empty (or whitespace-only) `voiceDescription` means no voice description: generate from the description, and clear the character's voice description on success. If omitted, the character's voice description is left unchanged.

- **Synchronous**: generated within one request, usually 5–20 s, whole request up to ~80 s. Success returns `200` and `CharacterView` with `voice.source` = `audition`; any previous voice is replaced.
- The voice description is saved to the character only when a voice is produced; on failure the character is unchanged.
- **Auto-voice on character creation** (`POST /api/v1/characters` without `voice`) uses the same process and the same daily quota, so creating a character also takes 5–20 s. If it doesn't succeed (quota used up, service unavailable, description or voice description blocked…), the character is still created (`201`) with `voice.status` = `none`; call `audition` separately (`h3 character audition`) to see the error code.
- If you upload or clear this character's voice while generation is running, your later action wins; the new voice isn't attached.
- Write the voice description as one noun phrase of about 30–45 Chinese characters (or ~20–30 English words): age and gender (optionally role), timbre, pitch, pace, tone and accent, e.g. 「二十五岁左右的女声，音色温暖、略带气声，音调中等偏低，语速中等，语气轻松」 ("female voice around 25, warm, slightly breathy timbre, medium-low pitch, medium pace, relaxed tone"). Don't describe how the speech unfolds, e.g. 「语速时快时慢」 ("pace varies") or 「句尾带笑」 ("laughs at the end of sentences"): descriptions that are too long or contain such things make the generator read the sentence twice or only half of it. See "Designing a voice" in [references/characters.md](https://minimax.xxt.ai/references/characters.md).
- **Quota**: 20 per account per day, reset at midnight Singapore time; when used up `429 audition_limit`. `GET /api/v1/characters/audition-quota` returns `{ "used": 2, "limit": 20, "resetsAt": "…" }`. Generation service errors and failed generations don't count.
- One generation per character at a time, otherwise `409 audition_in_progress`. Service unavailable: `503 voice_unavailable`.
- The voice description is content-checked like jobs; failures are `400 content_blocked`.

## 8. Songs

Write lyrics and a style, get two songs per run (the platform runs the same input twice for the user to pick). The website's Songs page (「出歌」) and these endpoints are the same system; an API Key works for all of it. How to write lyrics and style: [songwriting.md](https://minimax.xxt.ai/references/songwriting.md).

- **Price**: 100 credits per track; each submission makes two tracks, so 200 credits are held. Settled per track: 100 charged for each finished track, the hold for a failed track is refunded. No day/night difference. Insufficient balance: `402 insufficient_credits` (with `balance`, `required`).
- **Retention**: both tracks count from when the song run ends. Videos, songs and uploaded files are kept for 8 hours after the job ends, then deleted automatically. Please download them in time.
- **Wait time**: usually a few minutes per run, longer when queued. After submitting, `GET /api/v1/songs/<id>` about every 10 s until `status` is `succeeded`, `failed` or `canceled`.
- The song service moderates lyrics and style: naming a real singer or using copyrighted lyrics fails with `song_rejected`, credits refunded.

### Endpoints

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/songs/options` | Voice templates (`voiceTemplates`: `id`, `name`, `description`, `gender`), common styles (`genrePresets`), prices (`credits.perTrack`, `credits.perRun`), field limits (`limits`), duration range (`duration`), variety (`variety`), retention notice, and `available` (whether songs work right now). **Its numbers are authoritative** |
| `POST /api/v1/songs/lyrics` | AI lyrics (chat), free. See below |
| `POST /api/v1/songs/style` | AI style, free. See below |
| `POST /api/v1/songs` | Submit a song run; success `201`, returns the song view. See below |
| `GET /api/v1/songs?active=1` | My song runs, newest first. `active=1` lists only unfinished runs and those finished less than 8 hours ago (`active` accepts only `1`); omit it for all. Returns `{ "items": [song view] }` |
| `GET /api/v1/songs/<id>` | Get one song run. Missing or not yours: `404 song_not_found` |
| `POST /api/v1/songs/<id>/cancel` | Cancel; returns the song view, all held credits refunded. Only `queued` runs can be canceled; otherwise `409 song_not_cancelable` |
| `GET /api/v1/songs/<id>/tracks/<1 or 2>/file?disposition=attachment` | URL of this track's mp3 `{ "url", "expiresAt" }`, valid 1 hour. `disposition` is `attachment` (download with filename) or `inline` (default, plays in the browser) |
| `GET /api/v1/songs/<id>/tracks/<1 or 2>/cover?disposition=…` | URL of this track's cover, same form. No cover when `hasCover` is `false` |

When fetching URLs: track not 1 or 2, or no cover: `404 not-found`; track not finished: `409 file_not_ready`; past retention: `410 file_expired`. `GET` the `url` directly for the file; no key needed.

### `POST /api/v1/songs` · Submit a song

```json
{
  "title": "末班地铁",
  "lyrics": "[Verse 1]\n末班地铁的冷光…\n[Chorus]\n…",
  "style": "melancholic indie folk, wistful, around 80 BPM, warm breathy vocals, Mandarin Chinese vocals, fingerpicked acoustic guitar",
  "exclude": "autotune",
  "voice": { "template": "gentle-female" },
  "durationSec": 180,
  "variety": 1
}
```

(Title "The Last Subway"; lyrics begin "The cold light of the last subway…".)

| Field | Description |
|---|---|
| `lyrics` | Lyrics, max 5000 chars. Required unless `instrumental` is `true`; format in songwriting.md |
| `instrumental` | Instrumental, default `false`. Instrumentals can't have `lyrics` |
| `title` | Title, max 80 chars, optional |
| `style` | Style, max 1000 chars, one line of English phrases. **If omitted**, the platform has AI write one from the lyrics (counts as one AI style call); if AI is unavailable, `400 style_required`, so fill it in yourself |
| `exclude` | Elements to avoid, max 1000 chars |
| `voice` | Voice, one of three: `{ "template": "<voice template id>" }` (ids from `options`), `{ "custom": "<your own voice description>" }` (max 200 chars), or `{ "mine": true }` to sing with My Voice (create it first, see "My Voice" below; not for instrumentals). Omitted = model decides. Templates and custom voices are not sent to the music model: they feed the AI style writer (when `style` is omitted, or via `POST /api/v1/songs/style`), so a hand-written `style` must describe the timbre itself |
| `vocalGender` | Vocal gender: `"m"` male, `"f"` female, `null` unspecified. **Omitted** = the template's `gender` when `voice` is a template that has one, else unspecified; the song view returns the gender actually used. Ignored with My Voice |
| `audioInfluence` | How strongly My Voice is applied, integer 0–100, default `100`; only meaningful when `voice` is `{ "mine": true }`, otherwise ignored |
| `durationSec` | Duration in seconds, 10–360, multiple of 5. Omitted = model decides |
| `styleInfluence` | Style influence, integer 0–100 |
| `weirdness` | Weirdness, integer 0–100 |
| `variety` | Variety, integer 0–4, default `1` |

Checks run in this order; a failed step stops the rest. **Nothing that fails is charged**: parameters `400 invalid-input`; songs disabled (or the platform's file storage temporarily unavailable) `503 songs_unavailable`; My Voice used but no ready voice (not created, deleted or still being created) `409 my_voice_missing`; balance `402 insufficient_credits`; song service unreachable or out of quota, also `503 songs_unavailable`; empty style is written by AI, and if AI is unavailable or can't produce one `400 style_required` (this style call also counts toward the 100-per-day limit, `429 lyrics_daily_limit`). If two submissions arrive at once and the balance covers only one, only one succeeds.

**Song submission does not honor `Idempotency-Key`** (unlike job submission in section 4; sending it has no effect): submitting the same content again is a new song run and holds another 200 credits. So don't auto-retry song submission. On a network error or missing response, first `GET /api/v1/songs?active=1` to see whether the previous run was created (compare `createdAt`, `title`, `lyrics`); resubmit only if it really wasn't.

### `POST /api/v1/songs/lyrics` · AI lyrics

Chat with AI to write lyrics. Free, 100 per user per day (Singapore time); counted separately from AI style.

```json
{
  "messages": [{ "role": "user", "content": "写一首给毕业季的歌，有点伤感" }],
  "current": { "title": "", "lyrics": "", "style": "" }
}
```

(Message: "Write a slightly sad song for graduation season".)

- `messages`: the conversation, 1–100 messages, each `content` max 2000 chars; the last must be `user`. The platform reads only the last 20. To keep revising, include the previous `reply` as an `assistant` message.
- `current`: your current title, lyrics and style (possibly edited; AI treats it as the source of truth); may also include `instrumental` and `voice`.
- Returns `{ "reply", "title", "lyrics", "style" }`. `reply` is what the AI says to the user. If information is missing the AI asks first, and `title`, `lyrics`, `style` are `null`. When written or revised, `lyrics` is the complete song.
- Unavailable: `503 lyrics_unavailable` (write the lyrics yourself). Quota used up: `429 lyrics_daily_limit`. AI lyrics over 5000 chars: `400 lyrics_too_long`; ask it to shorten and retry.

### `POST /api/v1/songs/style` · AI style

```json
{ "keywords": "民谣，治愈，慢一点", "lyrics": "[Verse 1]\n…", "exclude": "autotune" }
```

(Keywords: "folk, healing, a bit slower".)

Fields: `keywords`, `lyrics`, `instrumental`, `voice`, `vocalGender`, `exclude`. **`lyrics` is required unless `instrumental` is `true`**: the AI picks the style from the lyrics' content and mood, so a few genre tags alone give a poor result (missing: `400 invalid-input` on `lyrics`). The rest are optional. Returns `{ "style", "exclude" }`, usable directly in a submission. Free, 100 per user per day; same limits and errors as above (`503 lyrics_unavailable`, `429 lyrics_daily_limit`).

### Song view

```json
{
  "id": "6f1c…",
  "status": "succeeded",
  "createdAt": "2026-10-27T10:00:00.000Z",
  "endedAt": "2026-10-27T10:03:20.000Z",
  "expiresAt": "2026-10-27T18:03:20.000Z",
  "title": "末班地铁",
  "lyrics": "…",
  "instrumental": false,
  "style": "…",
  "exclude": null,
  "voice": { "template": "gentle-female" },
  "vocalGender": null,
  "durationSec": 180,
  "styleInfluence": null,
  "weirdness": null,
  "variety": 1,
  "credits": { "held": 0, "charged": 200 },
  "error": null,
  "queueAhead": null,
  "tracks": [
    { "n": 1, "status": "ready", "title": "末班地铁", "durationSec": 181.2, "hasCover": true, "fileDeleted": false },
    { "n": 2, "status": "ready", "title": "末班地铁", "durationSec": 178.6, "hasCover": true, "fileDeleted": false }
  ],
  "retentionNotice": "成片、歌曲和上传的素材在任务结束后保存 8 小时，之后自动删除，请及时下载。",
  "webUrl": "https://minimax.xxt.ai/songs?song=9c41…"
}
```

(`retentionNotice` is the Chinese retention notice: "Videos, songs and uploaded files are kept for 8 hours after the job ends, then deleted automatically. Please download them in time.")

- `status`: `queued` queued, `submitting` submitting, `generating` generating, `saving` saving, `succeeded` done, `failed` failed, `canceled` canceled.
- `expiresAt`: 8 hours after the run ends; both tracks are deleted together. `null` until it ends.
- `webUrl`: this run on the website's song page (scrolled to and highlighted). Give it to the user.
- `queueAhead`: approximate number of runs ahead while queued; otherwise `null`.
- `credits.held`: credits still on hold; `credits.charged`: credits already charged.
- `tracks[].status`: `pending` not ready, `ready` downloadable, `failed` this track didn't come out. `fileDeleted: true` means the file was deleted or retention passed.
- `error`: failure reason, see table below; credits for failures are always refunded. A run with only one finished track counts as succeeded; the other track is refunded.

Song failure reasons (in `error`, not HTTP error codes):

| `error` | Situation | What to do |
|---|---|---|
| `song_rejected` | Blocked by the song service's moderation (e.g. a real singer's name, copyrighted lyrics) | Change lyrics or style and resubmit |
| `song_failed` | Generation failed | Resubmit later |
| `song_timeout` | Didn't finish in time | Submit again |
| `song_unavailable` | Song was generated but couldn't be retrieved | Resubmit later |
| `songs_unavailable` | Song service temporarily unavailable | Resubmit later |
| `my_voice_missing` | The My Voice used was deleted while queued; nothing generated, all credits refunded | Recreate the voice and resubmit |

### My Voice

Sing songs in the user's **own** voice. One voice per account; creating it costs no credits. Works with the login cookie and an API Key; responses don't include the song service's voice id.

**Both recordings must be made by the user personally**: one a cappella singing (20–240 s; anything over 240 s is cut to 240; no backing track, no original recording), and one reading the platform's verification phrase (max 15 s). The agent must not record on the user's behalf, nor use synthesized or someone else's voice. Both are WAV.

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/voice` | My Voice: `{ "voice": { "name", "status", "createdAt" } }`; `voice` is `null` if none. `status`: `creating` being created, `ready` usable |
| `GET /api/v1/voice/phrase` | Get a verification phrase `{ "phraseId", "phraseText", "expiresAt" }`. Must be used within 25 minutes, single use. Already have a voice: `409 my_voice_exists`; creation in progress: `409 my_voice_busy` |
| `POST /api/v1/voice` | Create the voice; success `201`, returns `{ "voice": … }`. Waits for the song service to verify, up to ~90 s |
| `DELETE /api/v1/voice` | Delete the voice; success `204` (also `204` if there is none). Creation in progress: `409 my_voice_busy`; song service can't delete it: `503 my_voice_unavailable` (voice kept; try again later) |

Steps to create the voice:

1. `GET /api/v1/voice/phrase` for `phraseId` and `phraseText`; ask the user to read `phraseText` aloud and record it as WAV.
2. Upload both recordings with the two steps of section 3; step 1 with `"kind": "audio"`, `"contentType": "audio/wav"`, `"purpose": "voice"`. You get two asset ids. Assets without `purpose: "voice"`: `400 my_voice_wrong_purpose`.
3. `POST /api/v1/voice`:

```json
{
  "singUploadId": "…",
  "verifyUploadId": "…",
  "singDurationSec": 63.5,
  "phraseId": "…",
  "name": "我的声音",
  "consent": true
}
```

- `singDurationSec`: length of the a cappella recording in seconds, > 0 and ≤ 240.5; under 20 s returns `422 my_voice_too_short`.
- `name`: voice name, 1–20 chars.
- `consent`: must be `true`, confirmed by the user personally: 「我确认这是我本人的声音，我已年满 18 岁，同意把录音交给出歌服务生成声音模型（可能涉及生物特征信息）。」 ("I confirm this is my own voice, I am at least 18, and I agree to give the recordings to the song service to build a voice model (this may involve biometric information)."). **Never tick it for the user.**
- Both recordings are deleted right after creation; unused recordings are deleted after 6 hours.
- Failures: already have a voice `409 my_voice_exists`; previous creation still running `409 my_voice_busy`; verification phrase expired `422 my_voice_phrase_expired`, not understood `422 my_voice_phrase_mismatch`; the two recordings are different people `422 my_voice_mismatch`; singing contains copyrighted content `422 my_voice_copyright`; song service rate limit `429 my_voice_rate_limited`, temporarily unavailable `503 my_voice_unavailable`. After a phrase failure, get a new phrase and read it again. All other errors are in section 9.

Singing with the voice: submit the song with `"voice": { "mine": true }`, optionally `"audioInfluence": 0–100` (higher = closer to the user's own voice, default 100). In the style, describe singing technique only, not vocal timbre.

## 9. Error codes

| Code | Status | Situation | What to do |
|---|---|---|---|
| `invalid-input` | 400 | Bad parameters: missing field, duration out of range, mode and assets don't match (lip-sync without vocal, vocal or last frame in another mode), angle `other` without a note, `raw` prompt missing `integrated_multimodal_description:`, idempotency key too long; lines split from the prompt have an unclear speaker, or exceed the line/length/speaker limits (`path` = `prompt`) | Fix according to `path` and `message` in `issues`; check section 4 |
| `unauthorized` | 401 | No key sent | Send `Authorization: Bearer <Key>` |
| `invalid_api_key` | 401 | Key wrong or revoked | Ask the user for another key |
| `banned` | 403 | Account banned | Contact the admin |
| `forbidden` | 403 | No permission | — |
| `not-found` | 404 | Endpoint doesn't exist, or no such take | Check the path |
| `payload-too-large` | 413 | Request body over 64KB | The prompt is too long |
| `too-many-requests` | 429 | Too frequent | Wait a bit and retry |
| `name_taken` | 409 | Duplicate key name, or the character name matches one of your undeleted characters | Use another name |
| `login_denied` | 403 | Link authorization: the user clicked Deny | Ask the user whether to authorize again; if yes, start over |
| `login_expired` | 410 | Link authorization: request expired (10 minutes) or wrong device code | Start over |
| `login_consumed` | 410 | Link authorization: this key was already issued; includes `keyName`. Most likely the previous poll's response was lost | Ask the user to revoke the key named `keyName` on the "API Key" page (「API Key」), then start over |
| `slow_down` | 429 | Link authorization: polling too often | Add 5 seconds to the poll interval |
| `login_decided` | 409 | Link authorization: request already approved or denied | — |
| `unsupported_type` | 400 | Upload type not supported | Use a supported format |
| `file_too_large` | 413 | File too large | Images ≤ 10MB, audio ≤ 20MB, video ≤ 50MB |
| `files_unavailable` | 503 | File service temporarily off | Retry later |
| `asset_not_found` | 404 | Asset missing, not yours or deleted | Upload again |
| `upload_missing` | 400 | Asset not uploaded, or uploaded size/type is wrong | Upload again |
| `insufficient_credits` | 402 | Not enough available credits; includes `balance`, `required` | Shorten, use one take, or ask the user to top up |
| `content_blocked` | 400 | Prompt or image failed the content check; includes `target`, `field` (section 4) | Change that field or replace that image per `field`. The platform doesn't produce pornography, nudity showing nipples, or content sexualizing minors |
| `moderation_unavailable` | 503 | Content check temporarily unavailable. For jobs it appears in `rejection` after review kept retrying for 30 minutes; credits refunded | Retry later |
| `lyrics_unavailable` | 503 | AI lyrics / AI style for songs temporarily unavailable (no LLM configured or LLM error) | Write lyrics and style yourself, or retry later |
| `lyrics_daily_limit` | 429 | Today's AI lyrics (or AI style) quota used up; 100 each per user per day, Singapore time | Come back tomorrow |
| `lyrics_too_long` | 400 | AI-written lyrics exceed 5000 chars | Ask the AI to shorten and retry |
| `songs_unavailable` | 503 | Songs temporarily unavailable (disabled, unreachable or out of quota); nothing submitted, nothing charged. If the song service fails after submission, it appears in the song view's `error` (section 8) | Retry later |
| `style_required` | 400 | Song submitted without a style and AI couldn't write one (AI style temporarily unavailable) | Fill in the style yourself, or resubmit later |
| `song_not_found` | 404 | Song run not found (missing or not yours) | Check the song id |
| `song_not_cancelable` | 409 | Song run already started or finished; only queued runs can be canceled | — |
| `translation_unavailable` | 503 | Translation temporarily unavailable | Use an English prompt, or retry later. With characters and quoted lines in the prompt, or promo without `lines`, the platform also needs this model to split lines / write the pitch, so English alone won't help: write `lines` yourself (promo can also use `sellingPoints`) or retry later |
| `job_not_found` | 404 | Job not found | Check the job id |
| `job_not_cancelable` | 409 | Job already finished | — |
| `file_not_ready` | 409 | File not ready yet | Wait for the job to finish |
| `file_expired` | 410 | Past the 8-hour retention period, deleted | Can't be recovered; generate again |
| `character_not_found` | 404 | Character: (also for characters in a job's `characters`, with `character`) missing, deleted, not yours, or the photo id doesn't belong to this character, or the character has no recording | List your characters first and use the returned ids |
| `character_limit` | 409 | Character: already 50 undeleted characters | Delete unused ones first |
| `too_many_photos` | 400 | Character: adding would exceed 8 photos | Delete one first |
| `last_photo` | 400 | Character: trying to delete the last photo | Add a new one, then delete the old |
| `wrong_purpose` | 400 | Character: photo or recording wasn't uploaded with `"purpose": "character"` | Re-upload with `"purpose": "character"` |
| `asset_in_use` | 409 | Character: this asset is already used as a photo or voice of some character | Upload another copy |
| `audition_limit` | 429 | Character: today's audition quota used up (20 per day, reset at midnight Singapore time) | Try tomorrow, or upload a recording |
| `audition_in_progress` | 409 | Character: a voice is already being generated for this character | Wait for it to finish, then retry |
| `voice_unavailable` | 503 | Character: voice generation service temporarily unavailable; doesn't count toward quota | Retry later, or upload a recording |
| `voice_template_unavailable` | 503 | Character: a voice template couldn't be copied to the character (file service trouble); the character keeps its voice | Retry later, or pick another way to give it a voice |
| `my_voice_exists` | 409 | My Voice: a voice already exists | Delete it before recreating |
| `my_voice_busy` | 409 | My Voice: voice is being created | Check again shortly |
| `my_voice_missing` | 409 | My Voice: using My Voice for a song but there is no ready voice (not created, deleted or still being created); also when the voice was deleted during creation | Recreate it |
| `my_voice_wrong_purpose` | 400 | My Voice: recording wasn't uploaded with `"purpose": "voice"` | Re-upload with `"purpose": "voice"` and use the new asset id |
| `my_voice_phrase_expired` | 422 | My Voice: verification phrase expired | Get a new phrase and read it again |
| `my_voice_phrase_mismatch` | 422 | My Voice: the phrase reading wasn't understood | Read it again, clearly, without extra words |
| `my_voice_mismatch` | 422 | My Voice: the verification phrase and the singing aren't the same person | Re-record one of them |
| `my_voice_too_short` | 422 | My Voice: a cappella singing under 20 s | Sing longer |
| `my_voice_copyright` | 422 | My Voice: recording judged to contain copyrighted content | Sing a cappella; no backing track or original recording |
| `my_voice_rate_limited` | 429 | My Voice: too many requests | Wait a while and retry |
| `my_voice_unavailable` | 503 | My Voice: voice creation service temporarily unavailable | Retry later |
| `asset_is_character` | 400 | Job: first frame, last frame, vocal or plain reference image uses a character-library asset (uploaded with `"purpose": "character"`); includes `field`: `firstFrame`, `lastFrame`, `audio`, `refs[i].asset`, and for promo `products[i].assets[j]`, `scene.asset` | Select the character in `characters`; to use it as a plain asset, re-upload without `purpose` |
| `asset_is_voice` | 400 | Job: first frame, last frame, vocal or plain reference image uses a My Voice recording (uploaded with `"purpose": "voice"`); includes `field`, same as `asset_is_character` | Re-upload without `purpose` to use it as a job asset |
| `asset_is_feedback` | 400 | Job: an asset field uses a feedback screenshot (uploaded with `"purpose": "feedback"`); includes `field`, same as `asset_is_character` | Re-upload without `purpose` to use it as a job asset |
| `character_exempt_only` | 400 | Job: this character can only be used in specific jobs; includes `character`: the character's name | Use another character |
| `character_voice_not_ready` | 409 | Job: a character with lines has no voice (a promo host always speaks, so must have a voice); includes `character`: the character's name | Give it a voice first: audition (`POST /api/v1/characters/{id}/voice/audition`, designs one from its voice description) or upload a recording (3–15 s); then resubmit: with the same `Idempotency-Key` if the submission was rejected right away; with a new one if it came in a job's `rejection` (that key now belongs to the failed job) |
| `ref_video_too_short` | 400 | Job: `seconds` longer than the motion video; includes `refVideoSeconds`: the motion video's length | Set `seconds` to at most `refVideoSeconds` |
| `feedback_daily_limit` | 429 | Feedback: already 20 notes in the last 24 hours; includes `limit` | Wait; one note per problem is enough (section 10) |
| `feedback_image_invalid` | 400 | Feedback: a screenshot in `images` can't be used; includes `field: "images"`, `index` (from 0) and `reason`: `not_found` (missing, not yours or deleted), `wrong_purpose` (not uploaded with `"purpose": "feedback"`), `not_image`, `not_uploaded` (file not uploaded or wrong size/type), `in_use` (already attached to another note) | Re-upload that screenshot with `"purpose": "feedback"` and use the new asset id (section 10) |
| `payments_unavailable` | 503 | Online payment isn't enabled or Stripe is temporarily unreachable; no order created | Retry later; if it keeps failing, tell the user to email hi@xxt.ai |
| `internal` | 500 | Server error | Retry later. Retrying a job submission with the same `Idempotency-Key` won't create a duplicate job (song submission ignores this key; check first, then resubmit, see section 8) |

On a network drop, or a non-JSON 502, 503 or 504 from the gateway, wait a few seconds and retry. Read endpoints are safe to retry freely; job submission (`POST /api/v1/jobs`) must reuse the same `Idempotency-Key`. Song submission (`POST /api/v1/songs`) ignores that key, so don't retry it blindly: first `GET /api/v1/songs?active=1` to see whether it went through, and resubmit only if not; otherwise another 200 credits may be held.

Song failure reasons (the song view's `error`): `song_rejected`, `song_failed`, `song_timeout`, `song_unavailable`. These aren't HTTP error codes; explanations and actions are in the last table of section 8.

## 10. Feedback

Tell the platform about a bug, an error you couldn't resolve, instructions that were wrong, unclear or missing, or something the user wishes worked differently. We review notes periodically and reply in `adminNote`. The CLI wraps this as `h3 feedback` (SKILL.md section 13).

| Endpoint | Purpose | Returns |
|---|---|---|
| `POST /api/v1/feedback`, body `{ "kind": "bug", "scenario": "mv", "message": "…", "jobId": "…", "songId": "…", "images": ["<asset id>"], "client": "…" }` | Send one note | `201 { "id", "kind", "scenario", "images", "status": "open", "createdAt" }` |
| `GET /api/v1/feedback` | My notes, newest first (latest 50) | `[{ "id", "kind", "scenario", "message", "jobId", "songId", "images", "status", "adminNote", "createdAt", "updatedAt" }]` |

- `kind`: `bug` (the platform misbehaved), `docs` (instructions wrong, unclear or missing), `idea` (suggestion or wanted feature).
- `message`: 5–2000 chars after trimming. Say what you did, what happened and what you expected; include the command, error code and job/song id. No API keys or personal data.
- `scenario` (optional): what the user is making: `mv` (music video), `drama` (short drama), `motion_swap` (motion swap), `product_ad` (product / promo ad), `song` (songs), `other`. We review notes by scenario.
- `jobId`, `songId` (optional): must be one of your own jobs / songs, otherwise `404 job_not_found` / `404 song_not_found`. **About a video, always pass `jobId`**: the video file is deleted after 8 hours, but we can still see the job's prompts, parameters, per-version errors and QC reasons.
- `images` (optional, up to 4 asset ids, no duplicates): screenshots showing the problem. Upload each first with section 3 and `"purpose": "feedback"` (JPG, PNG or WebP, ≤ 10MB), then pass the asset ids here in order. To grab a frame from a video: `ffmpeg -ss <seconds> -i out.mp4 -frames:v 1 shot.jpg`. Screenshots stay with the note (they are not deleted after 8 hours). Each must be yours, uploaded with `purpose: "feedback"`, an image, fully uploaded and not attached to another note; otherwise `400 feedback_image_invalid` with `index` (which one, from 0) and `reason` (`not_found`, `wrong_purpose`, `not_image`, `not_uploaded`, `in_use`).
- `client` (optional, ≤ 200 chars): your client and version, e.g. `h3 0.3.0; macOS-15.1; Python 3.9.6`.
- `status`: `open` (received) or `done` (handled). `adminNote`: our reply, `null` until we write one.
- Limit: 20 notes per user per 24 hours; beyond that `429 feedback_daily_limit`. Send one note per problem, not for mistakes you fixed yourself.
