Reference
Errors
All 21 codes the API can return, each with the status it always arrives with and the thing to do next. Codes are wire-stable — branch on error.code, never on the message.
The error envelope
Every failure has the same shape. data is absent; error is present.
{
"error": {
"code": "missing_scope",
"message": "This API key is missing the \"images:generate\" scope.",
"requestId": "0d1f…",
"details": { "requiredScope": "images:generate" }
}
}code— stable and machine-readable. A code’s meaning never changes inside/v1; new codes may be added.message— written for a human. Show it, log it, but never parse it.requestId— the same value as theX-Request-Idresponse header. It is how a support conversation becomes a lookup.details— present when there is something machine-usable to add: the field that failed, the scope that was missing, the credits that were short.
Every code
| Code | Status | Retry? | Meaning |
|---|---|---|---|
| invalid_request | 400 | Not without changing the 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. |
| unknown_field | 400 | Not without changing the request | A field the endpoint does not define. /v1 refuses unknown fields at every depth rather than ignoring them. |
| invalid_json | 400 | Not without changing the request | The body did not parse as JSON. |
| body_too_large | 413 | Not without changing the request | The JSON body is over the size limit. |
| unsupported_media_type | 415 | Not without changing the request | The request had a body with a Content-Type that is not application/json (or none at all). |
| invalid_api_key | 401 | Not without changing the request | 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. |
| missing_scope | 403 | Not without changing the request | The key authenticated, but it does not carry the scope this endpoint requires. |
| account_not_approved | 403 | Yes, with backoff | The Musavir account that owns the key is not approved for access yet. |
| account_disabled | 403 | Not without changing the request | The owning account can no longer use the API. |
| subscription_required | 403 | Not without changing the request | 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. |
| not_found | 404 | Not without changing the request | 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. |
| method_not_allowed | 405 | Not without changing the request | The path exists, but not under this method. |
| insufficient_credits | 402 | Not without changing the request | The credit balance could not cover the job. Nothing was started and nothing was charged. |
| api_key_cap_exceeded | 402 | Not without changing the request | 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. |
| idempotency_conflict | 409 | Not without changing the request | This Idempotency-Key was already used with a DIFFERENT request body. |
| idempotency_in_progress | 409 | Yes, after Retry-After | An earlier request with this same Idempotency-Key is still running. |
| idempotency_unresolved | 409 | Not without changing the request | An earlier request with this Idempotency-Key stopped before it finished, and whether it took effect is unknown. |
| conflict | 409 | Not without changing the request | 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. |
| rate_limited | 429 | Yes, after Retry-After | 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. |
| internal_error | 500 | Yes, with backoff | Something failed on our side. The response describes nothing further on purpose. |
| provider_unavailable | 503 | Yes, with backoff | 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. |
Code by code
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.
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.
A field the endpoint does not define. /v1 refuses unknown fields at every depth rather than ignoring them.
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.
The body did not parse as JSON.
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.
The JSON body is over the size limit.
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.
The request had a body with a Content-Type that is not application/json (or none at all).
Send Content-Type: application/json. A charset parameter is fine; a different media type is not.
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.
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.
The key authenticated, but it does not carry the scope this endpoint requires.
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.
The Musavir account that owns the key is not approved for access yet.
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.
The owning account can no longer use the API.
Contact support. This is checked live on every request, so a resolution applies immediately without reissuing keys.
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.
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.
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.
Check the method and path against the endpoint reference, and confirm the id came from a response to one of your own API calls.
The path exists, but not under this method.
The Allow response header lists the methods that path does accept.
The credit balance could not cover the job. Nothing was started and nothing was charged.
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.
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.
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.
This Idempotency-Key was already used with a DIFFERENT request body.
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.
An earlier request with this same Idempotency-Key is still running.
Wait for Retry-After (one second) and send the identical request again; you will get the original job back.
An earlier request with this Idempotency-Key stopped before it finished, and whether it took effect is unknown.
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.
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.
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.
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.
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.
Something failed on our side. The response describes nothing further on purpose.
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.
When a job fails
A job that reaches failed is a different kind of failure: the request that started it succeeded with a 202, and the render failed later. Read failure.code on the job — it is its own append-only vocabulary, and the raw provider text is never included.
Safety and moderation refused the prompt, a reference, or a face.
Change the prompt or the references. Retrying the identical request is refused the same way.
The balance ran out mid-render.
Nothing was charged for the failed job. Top up and start it again.
The provider took too long and the job was abandoned.
Start it again with a NEW idempotency key. Consider a shorter or simpler render.
The provider rejected or abandoned the render for its own reason.
Usually transient — start the job again. If it repeats for one prompt, treat that prompt as blocked.
Something else went wrong. The message carries whatever detail is safe to share.
Retry once; if it persists, quote the job id to support.
A failed character carries its own smaller set on the same field: insufficient_credits when the balance ran out mid-training (you were not charged) and training_failed when the face could not be built — see characters.
Running out of credits
A 402 insufficient_credits is the one failure that is not about the request. Nothing was started and nothing was charged — the balance simply could not cover the job. GET /v1/account/usage is where the whole picture is: the two credit buckets, what this key and the account have spent over the period, and an autoReload block.
Automatic top-ups charge the account owner’s saved card when the balance falls below a figure they chose, up to a ceiling per UTC calendar month. That is why a balance can go up with no purchase in your logs, and why it can suddenly stop doing so:
enabled,threshold,amountandmonthlyCap— the policy, as decimal strings. All four arenull/falsewhen it was never set up.monthToDate— credits added automatically since the first of the month. Compare it withmonthlyCap: once they meet, no further top-up runs until the month rolls over, however low the balance goes.lastErrorandlastAt— why the last attempt stopped. A declined or expired card reads here first; it does not otherwise announce itself to your integration.
Debugging a call
- Log
X-Request-Idon every response, success or failure. It is on all of them, and it is the only handle support has. - Read
error.details.pathfirst on a400. It names the exact field, prefixed with where it came from —body.refKeys[2],query.limit,params.id. - Check for a typo before checking the docs.
unknown_fieldmeans the field does not exist at all;invalid_requestmeans it exists and your value is wrong. They are separate codes precisely so a misspelled optional field does not look like a missing required one. - A
404is often a scope or origin problem, not a wrong id. Jobs created in the Musavir studio are invisible to API keys by design, and another account’s resources answer the same way. - A
401on a key that used to work means it was paused, revoked or expired — all three read the same on the wire. Check it in the dashboard.
For retry mechanics — which failures are safe to repeat, and with which key — see idempotency and retries.