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
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.
# 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}
]}')")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"
doneQuality 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
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
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.
{
"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.
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.