Skip to content
MV2API docsStudioDownload for agentsGet an API key

Guide

Characters

Train a face once from a handful of photographs, then cast it in every render that follows — draft, upload, train, poll.

The lifecycle

A character is created in three calls rather than one, and the split is deliberate. Selfies are biometric and large, so they go straight to storage — which means the character has to exist before there is a namespace to upload into. And training is the step that spends credits, so it is its own request, with its own Idempotency-Key.

draft → queued → training → ready, or failed. A draft costs nothing and starts nothing; you can hold one for as long as you like before it has photos. Creating and training need characters:write; reading needs characters:read.

Two safety states also stop automatic polling and paid training retries: awaiting_confirmation and reconciliation_pending. They carry an action block rather than a training failure. Branch on status or action.code and display action.message.

Create a draft

Create the character
CHARACTER_ID=$(curl -s -X POST https://api.musavir.ai/v1/characters \
  -H "Authorization: Bearer $MUSAVIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada"}' | jq -r '.data.id')

echo "$CHARACTER_ID"

The response is 201 with the character and a Location header. Keep the id — it is the namespace the selfies go into, and it is what you cast later.

Upload selfies

Presign into this character’s own upload endpoint, then PUT each file exactly as the uploads guide describes.

Presign the selfies
# sizeBytes must be each file's REAL size — it is signed into the upload URL.
SIZE_1=$(wc -c < ada-1.jpg)
SIZE_2=$(wc -c < ada-2.jpg)
SIZE_3=$(wc -c < ada-3.jpg)

TARGETS=$(curl -s -X POST https://api.musavir.ai/v1/characters/$CHARACTER_ID/uploads \
  -H "Authorization: Bearer $MUSAVIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n \
        --argjson size1 "$SIZE_1" \
        --argjson size2 "$SIZE_2" \
        --argjson size3 "$SIZE_3" \
        '{files:[
          {contentType:"image/jpeg",filename:"ada-1.jpg",sizeBytes:$size1},
          {contentType:"image/jpeg",filename:"ada-2.jpg",sizeBytes:$size2},
          {contentType:"image/jpeg",filename:"ada-3.jpg",sizeBytes:$size3}
        ]}')")
Upload each file to its target
echo "$TARGETS" | jq -r '.data.uploads[] | [.filename, .uploadUrl] | @tsv' |
while IFS=$'\t' read -r NAME URL; do
  curl -sS -X PUT "$URL" \
    -H "Content-Type: image/jpeg" \
    --data-binary @"$NAME"
done

Quality of input decides quality of output: several clear, well-lit photographs of one face, from a few angles, beats a large pile of similar frames.

Start training

Attach the selfies and train
curl -i -X POST https://api.musavir.ai/v1/characters/$CHARACTER_ID/actions/train \
  -H "Authorization: Bearer $MUSAVIR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "$(echo "$TARGETS" | jq '{selfieKeys: [.data.uploads[].uploadKey]}')"

The answer is 202 Accepted with the character, and a Location pointing back at the character itself: training is tracked on the character, not as a separate job resource.

Poll until ready

Poll the character
while :; do
  CHARACTER=$(curl -s https://api.musavir.ai/v1/characters/$CHARACTER_ID \
    -H "Authorization: Bearer $MUSAVIR_API_KEY")
  echo "$CHARACTER" | jq -r '.data.status + " " + (.data.progress|tostring) + "%"'
  case "$(echo "$CHARACTER" | jq -r '.data.status')" in
    ready|not_required|failed|awaiting_confirmation|reconciliation_pending) break ;;
  esac
  sleep 5
done

# Show the user any required review or billing action; do not retry training.
echo "$CHARACTER" | jq -r '.data.action.message // empty'

Training is slower than a render — poll every few seconds, not every second. progress is 0–100 and advisory.

A trained character
{
  "data": {
    "id": "b17e…",
    "object": "character",
    "name": "Ada",
    "status": "ready",
    "progress": 100,
    "selfieCount": 3,
    "avatar": {
      "url": "https://…",
      "expiresAt": "2026-08-22T11:42:00.000Z"
    },
    "failure": null,
    "action": null,
    "createdAt": "2026-08-22T10:31:12.000Z",
    "updatedAt": "2026-08-22T10:41:58.000Z"
  }
}

Cast the character

Pass ids in characterIds on POST /v1/images or POST /v1/videos. How many may appear in one render is maxCast in GET /v1/capabilities, and each one takes provider slots away from your reference images — the budget table is references.byCastSize in the same response.

Generate with the character cast
curl -X POST https://api.musavir.ai/v1/images \
  -H "Authorization: Bearer $MUSAVIR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "$(jq -n --arg id "$CHARACTER_ID" \
        '{prompt:"Ada in a rain-soaked alley, neon signs, 35mm", characterIds:[$id]}')"

A character that is not ready is a 409 conflict rather than a render that quietly ignores it. On video, a cast may need character sheets approved first: the job pauses at action_required and its requiredAction block carries the previews and the exact call that continues it.

When training fails

A failed character carries a failure block with a code you can branch on. The raw provider error is never returned:

  • insufficient_credits — the balance ran out before training finished. You were not charged; top up and train again.
  • training_failed — we could not build the face. Try again with several clear, well-lit photos of one person.

A failed re-train keeps the previous working avatar, so a character that still renders does not suddenly look empty.

Listing characters

GET /v1/characters returns the account’s characters newest first, cursor-paginated — follow pagination.nextCursor rather than assuming one page holds everything. Characters created in the studio appear alongside those created through the API: a character belongs to the account, and using one your integration did not create is the point of the scope.

Drafts are excluded from the list — a character that never started training must not sit in a library looking broken. GET /v1/characters/{id} returns it at every stage, draft included.