Guide
Reference uploads
Your own images, clips and audio, attached to a generation — without a byte of them passing through this API.
The shape of an upload
Every medium follows the same three steps, and they are separate calls on purpose:
- Presign. Ask for an upload target. You get back a URL, the exact headers to send with it, an expiry, and an opaque
uploadKey. - PUT. Send the bytes straight to storage, with those headers and no API key.
- Attach. Pass the
uploadKeyto a generation.
That is what keeps a 200 MB clip off this service entirely, and why the JSON body limit on a generation is so small: a request carries keys, not files. Presigning spends no credits and needs no Idempotency-Key — a repeated presign costs a wasted signature, not money.
There is one endpoint per medium — POST /v1/uploads/images, POST /v1/uploads/videos and POST /v1/uploads/audio — and all three take the same body: { "files": [{ "contentType": "…", "sizeBytes": …, "filename": "…" }] }. contentType and sizeBytes are required; filename is optional. They all need the uploads:write scope.
Presign a target
# sizeBytes must be the file's REAL size — it is signed into the URL.
SIZE=$(wc -c < reference-1.jpg)
curl -X POST https://api.musavir.ai/v1/uploads/images \
-H "Authorization: Bearer $MUSAVIR_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson size "$SIZE" \
'{files:[{contentType:"image/jpeg",filename:"reference-1.jpg",sizeBytes:$size}]}')"{
"data": {
"object": "upload_targets",
"uploads": [
{
"filename": "reference-1.jpg",
"uploadKey": "3f9a…/generation-refs/7c21….jpg",
"uploadUrl": "https://…",
"headers": { "Content-Type": "image/jpeg" },
"expiresAt": "2026-08-22T10:30:00.000Z",
"maxBytes": …
}
]
}
}filename is echoed back when you send one, so a batch can be matched to your files by name instead of by array index. maxBytes is the ceiling storage will actually enforce, and expiresAt is when the URL stops working — presign again rather than holding one open.
PUT the bytes
Send exactly the headers in headers. The content type is part of what was signed, so a PUT with a different one — or none — is refused by storage before a byte lands.
SIZE=$(wc -c < reference-1.jpg)
UPLOAD=$(curl -s -X POST https://api.musavir.ai/v1/uploads/images \
-H "Authorization: Bearer $MUSAVIR_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson size "$SIZE" \
'{files:[{contentType:"image/jpeg",filename:"reference-1.jpg",sizeBytes:$size}]}')")
UPLOAD_URL=$(echo "$UPLOAD" | jq -r '.data.uploads[0].uploadUrl')
UPLOAD_KEY=$(echo "$UPLOAD" | jq -r '.data.uploads[0].uploadKey')
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
--data-binary @reference-1.jpgAttach the key
Pass the keys to a generation in refKeys. They are opaque: store and resend them verbatim, never parse one, derive one from a filename, or build one by hand. A key outside your own namespace is rejected.
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 key "$UPLOAD_KEY" \
'{prompt:"Her jacket, same fabric, on a rain-soaked street at night", refKeys:[$key]}')"Video clips need normalizing
A reference clip has one extra step. After the PUT and before the generation, call POST /v1/uploads/videos/normalize. It reads the stored bytes server-side — a browser’s idea of a clip’s duration is not evidence — and returns the authoritative key to use from then on.
curl -X POST https://api.musavir.ai/v1/uploads/videos/normalize \
-H "Authorization: Bearer $MUSAVIR_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg key "$UPLOAD_KEY" '{uploadKey:$key}')"{
"data": {
"object": "video_reference",
"uploadKey": "3f9a…/generation-video-refs/be40….mp4",
"durationSeconds": …,
"width": …,
"height": …,
"normalized": true
}
}Attach normalized clips as videoRefKeys on POST /v1/videos.
Audio references
Same presign-and-PUT flow through POST /v1/uploads/audio, attached as audioRefKeys on a video. There is no normalize step. Whether the configured model accepts audio-only references, and how many, is reported by videos.references in GET /v1/capabilities.
How many references fit
The ceiling is not a constant: every character in the cast reserves provider slots, so a request with three characters attached has room for fewer reference images than one with none. GET /v1/capabilities publishes the whole table as images.references.byCastSize (and the video equivalent) — look up the row for your cast size.
Send more than the budget allows and the request is refused with a 400 naming the budget, your cast size and how many you sent. It is deliberately not truncated: silently dropping references would charge you for a render that quietly ignored half of what you attached.
Rules worth knowing
- Selfie keys are not reference keys. Character selfies live in their own namespace, accept different content types, and are refused here — see characters.
- Uploads are per account. A key can only presign into its own owner’s namespace, and a key from another account is a
404. sizeBytesis part of the signature. The URL is signed for exactly that many bytes, so the PUT must send a matchingContent-Length— a URL issued for a small file cannot be used to upload a large one. Send the real size (file.sizein a browser) and upload the file unmodified; if you compress or re-encode after presigning, presign again with the new size. The stored object is rechecked againstmaxByteswhen the reference is used as well.- Nothing here spends credits. The charge happens when you start the generation that uses the keys.