# Musavir API — coding-agent guide

> Use this file as the source of truth when implementing a Musavir API integration. It describes the public `/v1` backend, not the private studio GraphQL API. Never invent endpoints or model limits that are absent here.

## Non-negotiable integration rules

- Base URL: `https://api.musavir.ai/v1`.
- Read the API key from `MUSAVIR_API_KEY`; live keys begin with `mk_live_`. Send `Authorization: Bearer $MUSAVIR_API_KEY` on every `/v1` request. Never put a key in browser/mobile code, a URL, a log, or this file.
- Call `GET /v1/capabilities` before validating model-dependent values. Durations, resolutions, ratios, accepted content types and reference counts are runtime configuration; never hardcode values from an example.
- Requests with a body require `Content-Type: application/json`. Unknown fields are rejected at every depth.
- JSON bodies are capped at 256 KB by default. Upload media directly to the returned storage URL; do not base64 media into JSON.
- Every billable endpoint requires one `Idempotency-Key` (1–128 printable ASCII characters). Generate it once per logical operation, outside the retry loop, and reuse it only for the identical method/path/body.
- Log `X-Request-Id`. Branch on stable `error.code` and job `failure.code`, never on human messages.
- Accounting credit amounts, including `credits.reserved` and `credits.charged`, are decimal strings with six decimal places. Do not parse them through binary floating point for accounting. The separate `requiredAction.estimatedCredits` is an advisory JSON number or null; it is not a final charge.
- Store resource/job IDs and opaque upload keys. Never store signed result/upload URLs as durable identifiers, parse an upload key, or construct one yourself.
- Honour `Retry-After`. When rate limiting is enabled, also read `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and Unix-second `X-RateLimit-Reset`; their absence means limiting is not being enforced.

## Authentication and scopes

- `images:generate`: Start image generations. Spends credits.
- `videos:generate`: Start video generations, and approve the character sheets a video pauses on. Spends credits.
- `uploads:write`: Presign reference images, clips and audio, and normalize an uploaded clip. Puts objects in the account’s own storage namespace; spends nothing.
- `characters:read`: List characters and read one, including a freshly signed avatar URL.
- `characters:write`: Create character drafts, presign their selfies, and start training. Training spends credits.
- `jobs:read`: Read and list the jobs this account started through the API.
- `usage:read`: Read the credit balance, API usage, rate-limit budgets and model capabilities. The safest scope to grant: it cannot start work or spend anything.

Minimum scopes for a basic image flow: `usage:read`, `images:generate`, and `jobs:read`. Add `uploads:write` for references. Grant only what the integration needs.

```bash
export MUSAVIR_API_KEY="mk_live_your_key_here"
curl https://api.musavir.ai/v1/capabilities \
  -H "Authorization: Bearer $MUSAVIR_API_KEY"
```

## Response contract

- Single resource: `{ "data": { ... } }`.
- List: `{ "data": [ ... ], "pagination": { "hasMore": boolean, "nextCursor": string|null } }`. Cursors are opaque; return `nextCursor` as `cursor` unchanged.
- Error: `{ "error": { "code": string, "message": string, "requestId": string, "details"?: object } }`.
- Timestamps are ISO-8601 UTC strings and field names are camelCase.

## Generation workflow

1. Fetch `GET /v1/capabilities` and validate choices against the live response.
2. If references are needed, follow the upload workflow below and retain returned opaque keys.
3. `POST /v1/images` or `POST /v1/videos` with a new operation-level `Idempotency-Key`. A successful start returns `202 Accepted`, `Location`, `Retry-After`, and a job under `data`.
4. Poll `GET /v1/jobs/:id` using `pollAfterSeconds`/`Retry-After`; back off from roughly 2 seconds toward 10 seconds.
5. Job statuses are: `queued`, `running`, `action_required`, `succeeded`, `failed`. Stop render polling on `succeeded` or `failed`. A `running` job may be rendering or securing its finished output, so retain its id and never turn a short client timeout into a terminal result. On `action_required`, stop polling and branch on `requiredAction.type`; do not approve automatically.
6. A succeeded job carries `result.url`, `result.downloadUrl`, `result.contentType`, and `result.expiresAt`. These are temporary signed storage URLs. Re-read the job for a fresh link.
7. A failed job carries `failure.code`; it is not an HTTP failure—the start request already succeeded.
8. Render completion is not billing completion. A succeeded job with `credits.billingStatus: "reconciliation_pending"` has a delivered, usable result. Show "Charge pending": the reservation is not a final charge, and recorded `credits.charged` of zero does not mean free. Never start a paid retry or replacement to resolve billing; retain the job id and contact support if it persists. `settled` means settlement completed, `released` means the reservation was released, `abandoned` means the owner deleted the job while the provider still had it and its exact charge settles when the provider answers, and null is legacy or unsettled—not proof of a free result.

### Required job actions

- `approve_character_sheets`: the existing video flow is unchanged. Show `requiredAction.previews`, obtain user approval, then `POST /v1/jobs/:id/actions/approve` with `videos:generate`.
- `confirm_current_inputs`: an older image or video job lacks a trusted input snapshot. Display the current job prompt/specification, the previews in their returned order, `requiredAction.estimatedCredits`, `marginMultiplier`, and the existing `credits.reserved`. Ensure the previews can be reviewed. A null estimate is unavailable pricing, not zero: do not confirm it or unreadable previews; refresh the job or ask for help.
- After explicit user approval of that review, `POST /v1/jobs/:id/actions/confirm-inputs` with JSON `{ "inputFingerprint": "<the reviewed requiredAction.inputFingerprint>" }`. Requires `jobs:read` plus the matching `images:generate` or `videos:generate` scope. It resumes the same job and hold, without creating or funding another job. The estimate is not an additional reservation. A stale review returns `409`; re-read and obtain fresh user approval, never silently confirm changed inputs. Retrying the same accepted fingerprint is safe.

```bash
IDEMPOTENCY_KEY=$(uuidgen)
JOB_ID=$(curl -sS -X POST https://api.musavir.ai/v1/images \
  -H "Authorization: Bearer $MUSAVIR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{"prompt":"A cinematic portrait at dusk"}' | jq -r '.data.id')

curl -sS https://api.musavir.ai/v1/jobs/$JOB_ID \
  -H "Authorization: Bearer $MUSAVIR_API_KEY"
```

## Direct-to-storage upload workflow

1. Choose `/v1/uploads/images`, `/v1/uploads/videos`, or `/v1/uploads/audio` and send each file’s real `contentType`, `sizeBytes`, and optional `filename`.
2. For every target, PUT the exact bytes to `uploadUrl` using exactly the returned `headers`. Do **not** send the Musavir Authorization header to storage. The signed content length/type must match.
3. Retain `uploadKey`, not `uploadUrl`. Attach image keys as `refKeys`, normalized clip keys as `videoRefKeys`, and audio keys as `audioRefKeys`.
4. After uploading a video reference, call `POST /v1/uploads/videos/normalize`. On success, replace the original key with returned `uploadKey`; normalization may create a new object. It waits at most 25 seconds: on 503, honor Retry-After and retry the SAME original uploadKey and allowTrim. For immediate 202 receipts use `/v1/uploads/videos/prepare`, poll `/v1/uploads/preparations/:id`, and use `result.key` after succeeded.
5. Reference budgets depend on cast size. Read the relevant `references` section of `/v1/capabilities`; never truncate references silently.

## Character workflow

1. Create a draft with `POST /v1/characters`.
2. Presign selfies through `POST /v1/characters/:id/uploads`, PUT each file directly to storage with the returned headers, and retain the selfie keys.
3. Start training with `POST /v1/characters/:id/actions/train` and a new `Idempotency-Key` because training spends credits.
4. Poll `GET /v1/characters/:id` until `ready`, `failed`, `awaiting_confirmation`, or `reconciliation_pending`. Cast only ready character IDs in generation `characterIds`.
5. Character selfie keys and generation reference keys belong to different namespaces and are not interchangeable.
6. On `awaiting_confirmation`, stop automatic polling and paid training retries. `action.code` is `review_inputs`; show `action.message` and direct the user to review the current ordered photos and explicitly confirm training in Studio. Do not automatically replay the paid train action or create a replacement character.
7. On `reconciliation_pending`, stop automatic polling and paid training retries. `action.code` is `await_billing_reconciliation`; show `action.message` and "Charge pending". The saved avatar link can be viewed, but this is not `ready` for casting or proof of a final zero/free charge. Do not start another training; contact support if it persists.

## Retry and idempotency decisions

- Connection loss/timeout, `500`, `503`, or `409 idempotency_in_progress`: retry the identical request with the same idempotency key and backoff; honour `Retry-After`.
- `409 idempotency_conflict`: the key was reused for different work. Do not retry; allocate a new key only for a genuinely new logical operation.
- `409 idempotency_unresolved`: stop automatic retries and inspect the jobs list and balance; contact support if the outcome remains unclear. The key does not become reusable automatically. A new key creates new work and may spend again, so require an explicit new operation decision rather than assuming absence from a list proves no purchase occurred.
- `400`, `401`, `403`, or `404`: fix the request/auth/account/resource; blind retrying is not useful.
- Polling a job is a GET, not a replay of the generation POST.

## Endpoint reference

### GET /v1/account/usage

Scope: `usage:read` · class: `read` · billable: **no**

Credit balance, plan, auto-reload settings, and API usage for the current billing period.

Credit amounts are decimal strings with six decimal places. Usage is reported both account-wide and for the calling key, over the period named in `period`. The `autoReload` block explains why a balance may refill on its own: whether automatic top-ups are on, the balance that triggers one, how much each adds, the monthly ceiling, how much has been added so far this UTC calendar month, and why the last attempt stopped. It is READ-ONLY here, and there is no endpoint in /v1 that changes it — an API key may never alter a payment setting. Auto-reload is a standing permission to charge a saved card with nobody present, so switching it on or changing what it charges is a signed-in action in the Musavir studio, taken by the person who owns the card rather than by a key that could be copied out of a config file.

**Query fields**

_None._

**JSON body fields**

_None._

### GET /v1/account/limits

Scope: `usage:read` · class: `read` · billable: **no**

The calling key's rate-limit budgets, when limiting is enabled.

Reading this endpoint does not consume any budget. The generation budget is listed even though this call is not a generation, so a client can plan a batch before it starts.

**Query fields**

_None._

**JSON body fields**

_None._

### GET /v1/capabilities

Scope: `usage:read` · class: `read` · billable: **no**

Durations, resolutions, ratios, media types and reference limits the models accept.

The runtime source of truth. Values here are read from the live server configuration on every request, so they follow a model change without a documentation or SDK release. Never hardcode a duration or resolution against the documented examples instead of this.

**Query fields**

_None._

**JSON body fields**

_None._

### POST /v1/uploads/images

Scope: `uploads:write` · class: `upload` · billable: **no**

Presign reference-image uploads for an image or video generation.

PUT the file bytes directly to `uploadUrl` with exactly the headers in `headers`. The bytes never pass through this API. `sizeBytes` is REQUIRED and is signed into the URL: the upload must be exactly that many bytes or storage rejects it, so a URL cannot be reused for a larger file than it was issued for. `maxBytes` is rechecked from the stored object when the reference is used as well. The returned `uploadKey` is opaque. Store it and pass it back verbatim — never parse it, derive one from a filename, or construct one by hand; its layout is server-owned and can change without a version bump, and a key outside your own namespace is rejected. How many of these keys one generation may carry depends on its cast — each character reserves provider slots — so read `/v1/capabilities` for the budget rather than assuming this endpoint’s ceiling.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `files` | `object[]` | yes | limit comes from `images.references.providerMaxImages` in `GET /v1/capabilities` |
| `files[].contentType` | `string` | yes | accepted values come from `images.references.imageContentTypes` in `GET /v1/capabilities`; at most 128 characters |
| `files[].sizeBytes` | `integer` | yes | limit comes from `images.references.maxImageBytes` in `GET /v1/capabilities` |
| `files[].filename` | `string` | no | at most 255 characters |

### POST /v1/uploads/videos

Scope: `uploads:write` · class: `upload` · billable: **no**

Presign MP4/MOV reference-clip uploads for a video generation.

PUT the file bytes directly to `uploadUrl` with exactly the headers in `headers`. The bytes never pass through this API. `sizeBytes` is REQUIRED and is signed into the URL: the upload must be exactly that many bytes or storage rejects it, so a URL cannot be reused for a larger file than it was issued for. `maxBytes` is rechecked from the stored object when the reference is used as well. The returned `uploadKey` is opaque. Store it and pass it back verbatim — never parse it, derive one from a filename, or construct one by hand; its layout is server-owned and can change without a version bump, and a key outside your own namespace is rejected. A reference clip must then be normalized through `POST /v1/uploads/videos/normalize`, which is the only place its duration and dimensions are actually checked. How many clips one generation accepts is model-dependent — see `videos.references.maxVideoReferences` in `/v1/capabilities`.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `files` | `object[]` | yes | limit comes from `videos.references.maxVideoReferences` in `GET /v1/capabilities` |
| `files[].contentType` | `string` | yes | accepted values come from `videos.references.videoContentTypes` in `GET /v1/capabilities`; at most 128 characters |
| `files[].sizeBytes` | `integer` | yes | ceiling is the `maxBytes` returned beside each upload target |
| `files[].filename` | `string` | no | at most 255 characters |

### POST /v1/uploads/audio

Scope: `uploads:write` · class: `upload` · billable: **no**

Presign MP3/WAV reference-audio uploads for a video generation.

PUT the file bytes directly to `uploadUrl` with exactly the headers in `headers`. The bytes never pass through this API. `sizeBytes` is REQUIRED and is signed into the URL: the upload must be exactly that many bytes or storage rejects it, so a URL cannot be reused for a larger file than it was issued for. `maxBytes` is rechecked from the stored object when the reference is used as well. The returned `uploadKey` is opaque. Store it and pass it back verbatim — never parse it, derive one from a filename, or construct one by hand; its layout is server-owned and can change without a version bump, and a key outside your own namespace is rejected. How many audio references one generation accepts is model-dependent — see `videos.references.maxAudioReferences` in `/v1/capabilities`.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `files` | `object[]` | yes | limit comes from `videos.references.maxAudioReferences` in `GET /v1/capabilities` |
| `files[].contentType` | `string` | yes | accepted values come from `videos.references.audioContentTypes` in `GET /v1/capabilities`; at most 128 characters |
| `files[].sizeBytes` | `integer` | yes | ceiling is the `maxBytes` returned beside each upload target |
| `files[].filename` | `string` | no | at most 255 characters |

### POST /v1/uploads/videos/normalize

Scope: `uploads:write` · class: `upload` · billable: **no**

Normalize a reference clip, retaining the original metadata response contract.

After the PUT, queue durable preparation and wait at most 25 seconds. Success returns the original video_reference fields: authoritative uploadKey, durationSeconds, width, height, normalized. Always use that returned uploadKey. If preparation is still pending, HTTP 503 provider_unavailable and Retry-After include the original uploadKey and, once retained, preparationId. Retry with the SAME original uploadKey and allowTrim to recover the existing preparation; no decoder work runs inside this request and no credits are spent. For immediate 202 receipts use POST /v1/uploads/videos/prepare instead. An overlong clip returns VIDEO_TRIM_CONSENT_REQUIRED with measured durationSeconds/maxDurationSeconds. Obtain permission, then retry the original uploadKey with allowTrim: true. Raw uploads remain until the saved prepared result has passed its cleanup grace.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `uploadKey` | `string` | yes | at most 512 characters |
| `allowTrim` | `boolean` | no | — |

### POST /v1/uploads/videos/prepare

Scope: `uploads:write` · class: `upload` · billable: **no**

Queue reference-video preparation and immediately return a durable receipt.

Returns 202 and Location while queued or running, or 200 for a terminal replay. Poll GET /v1/uploads/preparations/:id until succeeded, then use result.key as the authoritative uploadKey. Retrying the same original uploadKey and allowTrim reuses the existing receipt. A failed receipt carries safe errorCode, errorMessage and optional measured trim errorDetails. No decoder work runs in this HTTP request and no credits are spent.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `uploadKey` | `string` | yes | at most 512 characters |
| `allowTrim` | `boolean` | no | — |

### GET /v1/uploads/preparations/:id

Scope: `uploads:write` · class: `read` · billable: **no**

Read an owned upload preparation receipt.

Poll until status is succeeded or failed. Succeeded result.key is the authoritative prepared upload key; failure contains a safe errorCode, errorMessage and optional trim consent errorDetails. This read does not decode media or buy a generation.

**Query fields**

_None._

**JSON body fields**

_None._

### POST /v1/characters

Scope: `characters:write` · class: `character` · billable: **no**

Create a character as a draft, before it has any photos.

Costs nothing and starts nothing. Keep the returned id: the draft is where selfies are uploaded and it is NOT returned by `GET /v1/characters`, which lists characters that have begun training — an abandoned draft must not sit in a library looking like a broken character. `GET /v1/characters/{id}` returns it throughout.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `name` | `string` | yes | at most 120 characters |

### POST /v1/characters/:id/uploads

Scope: `characters:write` · class: `upload` · billable: **no**

Presign selfie uploads into this character’s own namespace.

PUT each file directly to its `uploadUrl` with exactly the headers in `headers`, then pass the returned `uploadKey` values to the train action. These keys are NOT interchangeable with generation reference keys — each namespace is refused by the other — and the accepted content types differ: selfies take the HEIC/HEIF a phone camera produces and refuse GIF. Read `characters.selfieContentTypes` from `/v1/capabilities` rather than hardcoding either list.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `files` | `object[]` | yes | limit comes from `characters.maxSelfies` in `GET /v1/capabilities` |
| `files[].contentType` | `string` | yes | accepted values come from `characters.selfieContentTypes` in `GET /v1/capabilities`; at most 128 characters |
| `files[].sizeBytes` | `integer` | yes | ceiling is the `maxBytes` returned beside each upload target |
| `files[].filename` | `string` | no | at most 255 characters |

### POST /v1/characters/:id/actions/train

Scope: `characters:write` · class: `character` · billable: **yes — Idempotency-Key required**

Attach the uploaded selfies and start training. Returns 202.

Requires an `Idempotency-Key`: this is the call that spends credits, and a retry must return the same character rather than train it twice. Only keys returned by this character’s own upload endpoint are accepted. Poll `GET /v1/characters/{id}` until `status` is `ready` or `failed`. Stop automatic training retries for `awaiting_confirmation` (review the ordered photos in Studio) or `reconciliation_pending` (the saved avatar is available; billing needs reconciliation). A 402 here means the balance could not cover training.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `selfieKeys` | `string[]` | yes | limit comes from `characters.maxSelfies` in `GET /v1/capabilities` |

### GET /v1/characters

Scope: `characters:read` · class: `read` · billable: **no**

The account’s characters, newest first, cursor-paginated.

Characters created in the studio are listed alongside those created through the API: a character is an account resource, and using one an integration did not create is the point of the scope. Drafts that have not started training are excluded. Follow `pagination.nextCursor` rather than assuming one page holds everything.

**Query fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `limit` | `integer` | no | 1–100 |
| `cursor` | `string` | no | at most 512 characters |

**JSON body fields**

_None._

### GET /v1/characters/:id

Scope: `characters:read` · class: `read` · billable: **no**

One character: status, progress, and a freshly signed avatar URL.

The avatar URL is signed for this response only and dies at `avatar.expiresAt` — never store it, re-read the character instead. A character belonging to another account is a 404, the same answer as one that does not exist.

**Query fields**

_None._

**JSON body fields**

_None._

### POST /v1/jobs/:id/actions/confirm-inputs

Scope: `jobs:read` · class: `generation` · billable: **no**

Confirm the reviewed current inputs of an older paused job.

Requires jobs:read and the matching images:generate or videos:generate scope. Read requiredAction, show its ordered previews and pricing, obtain user approval, then echo inputFingerprint. A stale review returns 409. Reuses the existing job and reservation; retrying the same accepted fingerprint does not create or fund another job.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `inputFingerprint` | `string` | yes | matches `^[a-f0-9]{64}$` |

### POST /v1/images

Scope: `images:generate` · class: `generation` · billable: **yes — Idempotency-Key required**

Start an image generation and return the job to poll.

Only `prompt` is required — the server chooses a resolution and aspect ratio when they are omitted. Responds `202` with a job; poll `GET /v1/jobs/:id` (or follow `Location`) until it reaches `succeeded` or `failed`. Requires an `Idempotency-Key`, so a retried request returns the original job instead of rendering and charging twice. For media created for a Layers design, pass its `designId`; edit access is required and the admin-configured Layers multiplier applies (default 3.5×) instead of the API multiplier.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `prompt` | `string` | yes | at most 32000 characters |
| `characterIds` | `uuid[]` | no | limit comes from `images.maxCast` in `GET /v1/capabilities` |
| `templateId` | `uuid` | no | — |
| `designId` | `uuid` | no | — |
| `refKeys` | `string[]` | no | limit comes from `images.references.byCastSize` in `GET /v1/capabilities` |
| `resolution` | `enum` | no | accepted values come from `images.resolutions` in `GET /v1/capabilities` |
| `ratio` | `enum` | no | accepted values come from `GET /v1/capabilities` |

### POST /v1/videos

Scope: `videos:generate` · class: `generation` · billable: **yes — Idempotency-Key required**

Start a video generation and return the job to poll.

As `POST /v1/images`, plus duration, audio, and video/audio references. Accepted durations, resolutions and reference limits depend on the configured model — read `GET /v1/capabilities` rather than hardcoding them. A video whose cast needs character sheets pauses at `action_required`; approve it to continue.

**Query fields**

_None._

**JSON body fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `prompt` | `string` | yes | at most 32000 characters |
| `characterIds` | `uuid[]` | no | limit comes from `videos.maxCast` in `GET /v1/capabilities` |
| `templateId` | `uuid` | no | — |
| `designId` | `uuid` | no | — |
| `refKeys` | `string[]` | no | limit comes from `videos.references.byCastSize` in `GET /v1/capabilities` |
| `resolution` | `enum` | no | accepted values come from `videos.resolutions` in `GET /v1/capabilities` |
| `ratio` | `enum` | no | accepted values come from `GET /v1/capabilities` |
| `durationSeconds` | `integer` | no | limit comes from `videos.durationSeconds` in `GET /v1/capabilities` |
| `videoRefKeys` | `string[]` | no | limit comes from `videos.references.maxVideoReferences` in `GET /v1/capabilities` |
| `audioRefKeys` | `string[]` | no | limit comes from `videos.references.maxAudioReferences` in `GET /v1/capabilities` |
| `audio` | `boolean` | no | — |

### GET /v1/jobs/:id

Scope: `jobs:read` · class: `read` · billable: **no**

Fetch one job created through the API.

Result URLs are signed for this response only and carry `result.expiresAt` — sign them again by re-reading the job rather than storing them. Jobs created in the Musavir studio are not readable with an API key and answer `404`. A `running` job may still be rendering or may be securing its finished output; keep the job id and continue polling until a terminal status instead of imposing a short client-side timeout.

**Query fields**

_None._

**JSON body fields**

_None._

### GET /v1/jobs

Scope: `jobs:read` · class: `read` · billable: **no**

List jobs created through the API, newest first.

Every key on the account sees the same account-wide list, filtered to API-origin jobs. Pass `pagination.nextCursor` back as `cursor` for the next page; a cursor is opaque and must not be parsed or constructed.

**Query fields**

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `cursor` | `string` | no | at most 200 characters |
| `limit` | `integer` | no | 1–100 |
| `status` | `enum` | no | one of: `queued`, `running`, `action_required`, `succeeded`, `failed` |
| `kind` | `enum` | no | one of: `image`, `video` |
| `createdAfter` | `string` | no | ISO-8601 date-time with offset |
| `createdBefore` | `string` | no | ISO-8601 date-time with offset |

**JSON body fields**

_None._

### POST /v1/jobs/:id/actions/approve

Scope: `videos:generate` · class: `generation` · billable: **no**

Approve the character sheets a video job is waiting on.

Only meaningful while the job reports `action_required`; the sheets to review are in `requiredAction.previews`. Approving an already approved job returns that job, so a retry after a lost response is safe.

**Query fields**

_None._

**JSON body fields**

_None._

## HTTP error reference

### 400 invalid_request

The request reached a real endpoint but did not satisfy its schema — a missing required field, a value outside the accepted range, a path parameter that is not an id, a cursor that no longer resolves, or a billable call sent without an `Idempotency-Key`.

Action: Read `error.details.path`: it names the exact field, prefixed by where it came from (`body`, `query` or `params`). `error.details.issues` lists up to ten problems at once so one round trip fixes several.

Retry: **never**. Possible `error.details` fields: `location`, `path`, `issues`.

### 400 unknown_field

A field the endpoint does not define. `/v1` refuses unknown fields at every depth rather than ignoring them.

Action: Check the spelling against the endpoint reference. This is deliberate: a silently dropped `resoluton` would return `200` for a render you did not ask for, so the typo is reported instead of absorbed.

Retry: **never**. Possible `error.details` fields: `location`, `path`, `fields`.

### 400 invalid_json

The body did not parse as JSON.

Action: Serialize with a JSON encoder rather than string concatenation, and confirm nothing truncated the payload in transit. The parser message is deliberately not echoed back — it would quote your own body into our logs.

Retry: **never**.

### 413 body_too_large

The JSON body is over the size limit.

Action: Media never travels through this API. Presign an upload, PUT the bytes straight to storage, and send the returned `uploadKey` — a request carrying a prompt and a handful of keys is kilobytes.

Retry: **never**. Possible `error.details` fields: `maxBytes`.

### 415 unsupported_media_type

The request had a body with a `Content-Type` that is not `application/json` (or none at all).

Action: Send `Content-Type: application/json`. A charset parameter is fine; a different media type is not.

Retry: **never**. Possible `error.details` fields: `received`.

### 401 invalid_api_key

Authentication failed. Missing, malformed, unknown, paused, revoked and expired keys all produce this identical response — telling you which one it was would confirm to whoever holds a stolen key that it was once real.

Action: Check that the header reads `Authorization: Bearer mk_live_…` with the complete secret. If you cannot account for the key, revoke it and issue a new one; there is no way to re-reveal a key after creation.

Retry: **never**.

### 403 missing_scope

The key authenticated, but it does not carry the scope this endpoint requires.

Action: Add the scope to the key in the developer dashboard, or use a key that has it — `error.details.requiredScope` names it exactly. Scope changes take effect on the next request.

Retry: **never**. Possible `error.details` fields: `requiredScope`.

### 403 account_not_approved

The Musavir account that owns the key is not approved for access yet.

Action: Approval is an account-level state, not a key-level one — issuing another key changes nothing. Approval takes effect on the next request once granted.

Retry: **later**.

### 403 account_disabled

The owning account can no longer use the API.

Action: Contact support. This is checked live on every request, so a resolution applies immediately without reissuing keys.

Retry: **never**.

### 403 subscription_required

The API is a Musavir Pro feature and the owning account has no live subscription — it is on the free plan, or a paid period has ended. Only the SPENDING endpoints are closed: generation, upload and character requests. `GET /v1/account/usage`, `GET /v1/account/limits` and `GET /v1/capabilities` keep answering, so a lapse never leaves you unable to see your own balance.

Action: Subscribe to (or renew) Musavir Pro on the Billing page of the dashboard — `error.details.plan` names the plan the refusal was made against. A trial counts as subscribed: it requires a card and converts automatically, so evaluating the API does not require paying first. Access resumes on the next request; keys do not need reissuing.

Retry: **never**. Possible `error.details` fields: `plan`.

### 404 not_found

No such endpoint, or no such resource FOR THIS KEY. A resource that belongs to another account, and a job created in the Musavir studio rather than through the API, both answer `404` rather than `403` — a `403` would confirm the id names something real.

Action: Check the method and path against the endpoint reference, and confirm the id came from a response to one of your own API calls.

Retry: **never**.

### 405 method_not_allowed

The path exists, but not under this method.

Action: The `Allow` response header lists the methods that path does accept.

Retry: **never**. Possible `error.details` fields: `allow`.

### 402 insufficient_credits

The credit balance could not cover the job. Nothing was started and nothing was charged.

Action: Read `error.details.reason` to choose the remedy: `subscribe` means a lapsed subscription froze the granted balance, `buy_credits` means the account is simply out. `requiredCredits` and `availableCredits` are decimal strings. If the account has automatic top-ups switched on, `GET /v1/account/usage` reports why the last one did not land under `autoReload.lastError` — a declined card and a month that has reached its ceiling both surface there, and both look like this error from the outside.

Retry: **never**. Possible `error.details` fields: `reason`, `requiredCredits`, `availableCredits`.

### 402 api_key_cap_exceeded

The KEY hit its own monthly credit cap — not the account's balance. Caps are set per key in the developer dashboard and reset on the first of each UTC calendar month. The ceiling counts credits RESERVED as well as credits already settled, so jobs still running count against it; that is what stops a burst of concurrent requests from crossing a cap they each individually respected.

Action: Raise or clear the cap on that key in the developer dashboard, or send the request with a key that has room. Buying credits does not help — the account balance was never the constraint. `monthlyCreditCap`, `usedCredits`, `remainingCredits` and `requiredCredits` are decimal strings.

Retry: **never**. Possible `error.details` fields: `monthlyCreditCap`, `usedCredits`, `remainingCredits`, `requiredCredits`.

### 409 idempotency_conflict

This `Idempotency-Key` was already used with a DIFFERENT request body.

Action: Use one key per distinct request. Replaying the earlier job here would hand you a render you never asked for, so the mismatch is refused instead. Field order does not count as a difference — the body is canonicalized before it is compared.

Retry: **never**.

### 409 idempotency_in_progress

An earlier request with this same `Idempotency-Key` is still running.

Action: Wait for `Retry-After` (one second) and send the identical request again; you will get the original job back.

Retry: **header**.

### 409 idempotency_unresolved

An earlier request with this `Idempotency-Key` stopped before it finished, and whether it took effect is unknown.

Action: Do NOT resend with the same key — it will keep returning this. Check `GET /v1/jobs` and your balance to see whether the original request landed, then retry with a NEW `Idempotency-Key` if it did not.

Retry: **never**.

### 409 conflict

The request is well-formed but the resource is not in a state that allows it — casting a character that has not finished training is the case you will actually meet.

Action: Read the resource first and act on its current `status`; `details.characterReady` is `false` when a cast member was the problem. The message names the state that blocked the call.

Retry: **never**. Possible `error.details` fields: `characterReady`.

### 429 rate_limited

A per-minute budget was exhausted. Rate limiting is NOT enforced on this deployment today, so you should not see this code — the mechanism exists, and the ceilings are switched off.

Action: Honour `Retry-After` and back off. When limiting is enabled, every authenticated response also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; until it is, those headers are deliberately absent rather than advertising a budget nothing enforces.

Retry: **header**. Possible `error.details` fields: `limit`, `remaining`, `resetSeconds`.

### 500 internal_error

Something failed on our side. The response describes nothing further on purpose.

Action: Retry with the same `Idempotency-Key` — that is exactly the case the header exists for. If it persists, quote `error.requestId` to support; it resolves the full server-side record.

Retry: **later**.

### 503 provider_unavailable

A capability exists but is not currently available — most often video generation, which reports itself as `videos.available: false` in `GET /v1/capabilities` before you spend a request finding out.

Action: Check `GET /v1/capabilities` first, and retry later. Nothing about your request was wrong, and nothing was charged.

Retry: **later**.

## Asynchronous job failure reference

### content_blocked

Safety and moderation refused the prompt, a reference, or a face.

Action: Change the prompt or the references. Retrying the identical request is refused the same way.

Retry: **never**.

### insufficient_credits

The balance ran out mid-render.

Action: Nothing was charged for the failed job. Top up and start it again.

Retry: **never**.

### character_unavailable

A cast member could not be used for this render — its portrait is not ready, or was rejected.

Action: Read the character and check its `status`. The message says whether this is temporary.

Retry: **never**.

### timed_out

The provider took too long and the job was abandoned.

Action: Start it again with a NEW idempotency key. Consider a shorter or simpler render.

Retry: **later**.

### provider_unavailable

Our provider account cannot serve this render — a model that is not enabled, for instance.

Action: Not something your request can fix. Retry later, and check `GET /v1/capabilities` for what is currently available.

Retry: **later**.

### provider_error

The provider rejected or abandoned the render for its own reason.

Action: Usually transient — start the job again. If it repeats for one prompt, treat that prompt as blocked.

Retry: **later**.

### generation_failed

Something else went wrong. The message carries whatever detail is safe to share.

Action: Retry once; if it persists, quote the job id to support.

Retry: **later**.

## Final implementation checklist

- [ ] Key is server-side and loaded from an environment/secret manager.
- [ ] Capabilities are fetched instead of copied into source.
- [ ] Billable retries reuse one stored idempotency key and identical canonical request.
- [ ] Upload PUTs use returned headers and no Musavir Authorization header.
- [ ] IDs/opaque keys are persisted; signed URLs are refreshed, not persisted as identity.
- [ ] Polling honours server timing and handles `action_required`, `succeeded`, and `failed`.
- [ ] Legacy input confirmation echoes only the fingerprint whose ordered previews and estimate the user reviewed; stale reviews require fresh approval.
- [ ] Pending billing is displayed as pending, never final zero/free, and never triggers a paid retry.
- [ ] Character `awaiting_confirmation` and `reconciliation_pending` stop automatic training retries and display `action.message`.
- [ ] Error handling branches on stable codes and logs `X-Request-Id`.
- [ ] Credit decimal strings stay decimal-safe.

Human-readable documentation is available under `/docs/api` on the same site that supplied this file.
