Skip to content
MV2API docsStudioDownload for agentsGet an API key

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:

  1. Presign. Ask for an upload target. You get back a URL, the exact headers to send with it, an expiry, and an opaque uploadKey.
  2. PUT. Send the bytes straight to storage, with those headers and no API key.
  3. Attach. Pass the uploadKey to 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

Presign one reference image
# 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}]}')"
Response
{
  "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.

Presign, then upload the file
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.jpg

Attach 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.

Generate with the reference attached
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.

Normalize an uploaded clip
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}')"
Response
{
  "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.
  • sizeBytes is part of the signature. The URL is signed for exactly that many bytes, so the PUT must send a matching Content-Length — a URL issued for a small file cannot be used to upload a large one. Send the real size (file.size in 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 against maxBytes when the reference is used as well.
  • Nothing here spends credits. The charge happens when you start the generation that uses the keys.