API reference
API error codes
One error envelope, one stable code per failure, and a doc_url that lands on this page. 45 codes across 8 families — jump to a family, or search the page for the code you received.
The contract
One error object, returned the same way everywhere
Every failure — whichever API, whichever stage — is serialised by the same response builder. Clients parse one shape, write one handler.
Fields
| Field | Required | Purpose |
|---|---|---|
type | Required | Coarse family — one of the 8 listed below. Branch on this first. |
code | Required | Stable machine-readable identifier in snake_case. |
message | Required | Human-readable English sentence that says how to recover. |
param | Optional | Names the offending field so a form or SDK can highlight it. |
request_id | Required | End-to-end trace ID, identical to the X-Request-Id response header. |
doc_url | Required | Deep link to this page, anchored on the exact code. |
retryable | Required | Boolean the SDK uses to decide whether to retry automatically. |
details | Optional | Structured extras, e.g. required_credits and available_credits. |
{
"error": {
"type": "billing_error",
"code": "insufficient_credits",
"message": "Not enough credits for this job. Required 120, available 45.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2N9R",
"doc_url": "https://mlslabs.io/docs/errors.html#insufficient_credits",
"retryable": false,
"details": {
"required_credits": 120,
"available_credits": 45
}
}
}Why code is frozen and message is not. Clients are only ever expected to branch on code; message is prose and may be reworded at any time. A published code never changes meaning — new codes get added, existing ones are marked deprecated and honoured for at least 12 months.
Same shape in three places
Sync response, async job, webhook
Polling and webhooks carry the identical object, so a failure handler written once covers the whole async job model.
{
"job_id": "job_01J8ZK3M9QX4HD2N7P",
"status": "failed",
"error": {
"type": "billing_error",
"code": "insufficient_credits",
"message": "Not enough credits for this job. Required 120, available 45.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2N9R",
"doc_url": "https://mlslabs.io/docs/errors.html#insufficient_credits",
"retryable": false,
"details": {
"required_credits": 120,
"available_credits": 45
}
},
"billing": {
"estimated_credits": 120,
"charged_credits": 0,
"refunded": true
}
}{
"event": "job.failed",
"created_at": "2026-09-11T02:00:00Z",
"data": {
"job": {
"job_id": "job_01J8ZK3M9QX4HD2N7P"
},
"error": {
"type": "billing_error",
"code": "insufficient_credits",
"message": "Not enough credits for this job. Required 120, available 45.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2N9R",
"doc_url": "https://mlslabs.io/docs/errors.html#insufficient_credits",
"retryable": false,
"details": {
"required_credits": 120,
"available_credits": 45
}
}
}
}All codes
45 error codes in 8 families
Each code below is a permanent anchor — link to any row directly, e.g. /docs/errors.html#insufficient_credits. That is exactly the URL returned in doc_url.
Authentication & authorization
auth_errorHTTP 401 / 403The credential is missing, wrong, or not allowed to call this API. Nothing to retry — the caller has to send a working key. Kept strictly apart from billing errors below.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| missing_api_key | 401 | No X-API-Key header on the request | Send the key: -H "X-API-Key: mls_live_..." | No |
| invalid_api_key | 401 | The key does not exist or is malformed | Re-copy the key from the dashboard and check for stray whitespace | No |
| expired_api_key | 401 | The key passed its expiry date | Rotate the key in the dashboard | No |
| revoked_api_key | 401 | The key was revoked manually or by a leak scan | Issue a new key — the old one stays dead | No |
| insufficient_scope | 403 | The key is not scoped to this API or project | Use a key whose scope covers this endpoint | No |
| account_suspended | 403 | The account was suspended for abuse or an unpaid balance | Contact support | No |
Billing & credits
billing_errorHTTP 402The request itself is fine — the account cannot pay for it. Always HTTP 402, never 401, so client dashboards can route the user to top-up instead of key rotation.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| insufficient_credits | 402 | Balance is below the estimated cost of the job | details carries the exact shortfall — top up and resend | No |
| spend_limit_exceeded | 402 | The monthly spend cap was reached | Raise the cap or wait for the next billing period | No |
| plan_not_supported | 402 | The plan does not include this API, resolution or tier | Upgrade the plan for the feature | No |
| payment_failed | 402 | The card on file was declined or has expired | Update the payment method | No |
| subscription_inactive | 402 | The subscription lapsed | Reactivate the subscription | No |
Rate limits & concurrency
rate_limit_errorHTTP 429 / 503More work in flight than the tier allows. These are safe to repeat: the SDK backs off automatically and honours the Retry-After header.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| rate_limit_exceeded | 429 | Requests per second above the tier limit | Back off for Retry-After seconds, then resend | Yes |
| concurrency_limit_exceeded | 429 | More jobs running at once than the tier allows | Wait for an in-flight job to finish, then resubmit | Yes |
| quota_exceeded | 429 | The period quota is used up (e.g. monthly processing minutes) | Upgrade the tier or wait for the quota to reset | No |
| queue_full | 503 | The processing queue is saturated | Retry with exponential backoff | Yes |
Request & media
invalid_request_errorHTTP 400 / 413 / 415 / 422The request reached the API but the payload is wrong. param names the offending field so forms and SDKs can highlight it — fix it and resend, since repeating the same body will fail the same way.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| missing_parameter | 400 | A required field was not sent | Send the field named in param | No |
| invalid_parameter | 400 | A value is out of range or the wrong type | Correct the value against the API reference | No |
| invalid_url | 400 | The source URL is unreachable or its signature expired | Regenerate a fresh signed URL | No |
| duration_exceeded | 400 | The input is longer than this API accepts | Split the media or move to a higher tier | No |
| file_too_large | 413 | The payload is above the size limit | Use an object-storage URL instead of a direct upload | No |
| unsupported_format | 415 | The container format is not supported | Transcode to MP4, MOV or WAV and resend | No |
| unsupported_codec | 415 | The codec inside the container is not supported | Transcode the video or audio stream and resend | No |
| invalid_media | 422 | The file is corrupt or cannot be decoded | Check the source file plays locally first | No |
| content_policy_violation | 422 | The content breaches the usage policy | Replace the asset — do not retry | No |
Resources & jobs
resource_errorHTTP 404 / 409The resource is missing, or the request conflicts with the current state of a job.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| job_not_found | 404 | The job_id does not exist or aged out of retention | Keep the ID from the submit response and re-check it | No |
| asset_not_found | 404 | The referenced output asset or upload is gone | Re-run the job or re-upload the asset | No |
| job_not_cancellable | 409 | The job already finished or is past the cancel window | Stop cancelling and read the final job status | No |
| idempotency_conflict | 409 | The same Idempotency-Key was reused with a different body | Send a new key, or resend the identical body | No |
| resource_locked | 409 | The resource is in use by another job | Retry once the other job completes | Yes |
Processing failures
processing_errorHTTP 422 / 500 / 502 / 504The job was accepted and then failed inside the pipeline. These arrive on GET /jobs/{id} with status: "failed" and in the job.failed webhook, using the same envelope.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| processing_failed | 422 | Generic pipeline failure with no more specific cause | Retry once — if it persists, send the request_id to support | No |
| out_of_memory | 500 | Resolution or duration exceeds the worker configuration | Lower the target resolution or split the input | No |
| upstream_error | 502 | A dependency behind the API failed | Retry after a short delay | Yes |
| timeout | 504 | Processing exceeded the job time limit | Retry, or split into smaller jobs | Yes |
| drm_protected | 422 | The input is DRM-protected and cannot be decoded | Supply a DRM-free source | No |
| no_speech_detected | 422 | No speech found in a subtitle or audio job | Check the audio track carries usable dialogue | No |
| job_cancelled | 409 | The job was cancelled by you or by the system | No action — stop polling this job | No |
Service availability
server_errorHTTP 500 / 503Our side of the wire. Retry with backoff; if it keeps failing, open a ticket with the request_id so the call can be traced end to end.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| internal_error | 500 | An unexpected exception inside the API | Report it with the request_id | Yes |
| service_unavailable | 503 | The service is temporarily unavailable | Retry with exponential backoff | Yes |
| maintenance_window | 503 | Planned maintenance | Wait for the maintenance window to close | Yes |
| region_unavailable | 503 | The target region is degraded | Retry against another region | Yes |
UE Plugin licensing
license_errorHTTP 402 / 403Returned by the 3DGS/4DGS UE Plugin licence endpoint, so the plugin can show a precise message in the editor instead of failing silently.
| Code | HTTP | When it happens | What to do | SDK auto-retry |
|---|---|---|---|---|
| license_invalid | 403 | The licence key is not recognised | Sign out and activate again | No |
| license_expired | 403 | The licence term ended | Renew to restore watermark-free output | No |
| activation_limit_reached | 403 | The plan's activated-device count is exhausted | Deactivate an old machine, then activate this one | No |
| device_not_activated | 403 | This machine has not been activated yet | Run the activation flow in the plugin panel | No |
| offline_grace_expired | 403 | The offline grace period ended and online validation is due | Connect to the internet once to restore it | Yes |
HTTP status
Status codes and what they mean
The status tells you who has to act; the code tells you exactly what to change.
| Status | Meaning | Retry |
|---|---|---|
| 400 | Malformed request or bad parameter | No — fix the payload |
| 401 | Not authenticated / credential invalid | No — send a working key |
| 402 | Payment required / out of credits | No — top up, then resend |
| 403 | Authenticated but not permitted / account restricted | No |
| 404 | Resource does not exist | No |
| 409 | Conflicts with current resource state | Depends on the code |
| 413 | Payload too large | No — use object storage |
| 415 | Media type not supported | No — transcode first |
| 422 | Semantically invalid / processing failed | Follow retryable |
| 429 | Rate limit or concurrency limit hit | Yes — honour Retry-After |
| 500 | Internal error on our side | Yes — with backoff |
| 502 / 504 | Upstream failure / processing timeout | Yes — with backoff |
| 503 | Service unavailable or queue saturated | Yes — with backoff |
401 and 402 are deliberately kept apart: a bad credential and an empty balance are different user actions, and collapsing them into one status is the single most common API design mistake.
Retries
What the SDK does for you
Every official SDK reads retryable from the envelope instead of guessing from the status code. When a retry is safe it also replays the original Idempotency-Key, so a network hiccup can never bill the same job twice.
- Typed exceptions per family — no string parsing
- Automatic backoff that respects
Retry-After - Idempotency keys carried through retries
request_idsurfaced on every exception for support
| Situation | SDK behaviour |
|---|---|
retryable: true with 429 / 503 / 504 | Exponential backoff, up to 3 attempts, always honouring Retry-After |
retryable: true with 500 / 502 | Exponential backoff, up to 2 attempts |
retryable: false | No retry — the SDK raises a typed exception immediately |
| Network timeout with no response | Retried, but sends the same Idempotency-Key so the job is never charged twice |
processing_failed on a finished async job | Never retried automatically — the business layer decides |
Billing
Failed jobs are never charged
Errors and credits are bound together, because a charge for work that never happened costs far more trust than the credits are worth.
- Pre-authorise, then settle. Submitting freezes the estimated credits; success settles the real usage.
- Failure releases everything. A failed job reports
charged_credits: 0andrefunded: truein the job response and the webhook. - Partial failures settle pro rata, with the per-item breakdown in
details. - Quota errors always carry numbers, so the UI can render the exact shortfall instead of a generic message.
# insufficient_credits always quantifies the gap
need = err.details["required_credits"] - err.details["available_credits"]
ui.show("Top up " + str(need) + " credits to run this job")
# a finished-but-failed job is free
job = client.sdr2hdr.get(job_id)
assert job.billing.charged_credits == 0
assert job.billing.refunded is TrueTry it in Postman
Saved 401, 402 and 429 responses
The collection ships these six saved examples, so you can see each envelope in the Postman response pane — and against a mock server — before writing a single line of client code.
GET /v1/account/credits
HTTP/1.1 401 Unauthorized
X-Request-Id: req_01J8ZK3M9QX4HD2N7P
Content-Type: application/json
{
"error": {
"type": "auth_error",
"code": "invalid_api_key",
"message": "The API key is not valid. Check the X-API-Key header or rotate the key in the dashboard.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2N7P",
"doc_url": "https://mlslabs.io/docs/errors.html#invalid_api_key",
"retryable": false
}
}GET /v1/account/credits
HTTP/1.1 401 Unauthorized
X-Request-Id: req_01J8ZK3M9QX4HD2N8Q
Content-Type: application/json
{
"error": {
"type": "auth_error",
"code": "missing_api_key",
"message": "No API key was supplied. Send it in the X-API-Key header.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2N8Q",
"doc_url": "https://mlslabs.io/docs/errors.html#missing_api_key",
"retryable": false
}
}POST /v1/sdr2hdr/jobs
HTTP/1.1 402 Payment Required
X-Request-Id: req_01J8ZK3M9QX4HD2N9R
Content-Type: application/json
{
"error": {
"type": "billing_error",
"code": "insufficient_credits",
"message": "Not enough credits for this job. Required 120, available 45.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2N9R",
"doc_url": "https://mlslabs.io/docs/errors.html#insufficient_credits",
"retryable": false,
"details": {
"required_credits": 120,
"available_credits": 45
}
}
}POST /v1/sdr2hdr/jobs
HTTP/1.1 402 Payment Required
X-Request-Id: req_01J8ZK3M9QX4HD2NAR
Content-Type: application/json
{
"error": {
"type": "billing_error",
"code": "spend_limit_exceeded",
"message": "Monthly spend limit reached. This job would exceed the cap on your account.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2NAR",
"doc_url": "https://mlslabs.io/docs/errors.html#spend_limit_exceeded",
"retryable": false,
"details": {
"spend_limit_usd": 700,
"spent_usd": 700,
"reset_at": "2026-10-01T00:00:00Z"
}
}
}POST /v1/sdr2hdr/jobs
HTTP/1.1 429 Too Many Requests
X-Request-Id: req_01J8ZK3M9QX4HD2NBR
Retry-After: 2
Content-Type: application/json
{
"error": {
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded. This plan allows 20 requests per second.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2NBR",
"doc_url": "https://mlslabs.io/docs/errors.html#rate_limit_exceeded",
"retryable": true,
"details": {
"limit": 20,
"window_seconds": 1,
"retry_after_seconds": 2
}
}
}POST /v1/sdr2hdr/jobs
HTTP/1.1 429 Too Many Requests
X-Request-Id: req_01J8ZK3M9QX4HD2NCR
Retry-After: 5
Content-Type: application/json
{
"error": {
"type": "rate_limit_error",
"code": "concurrency_limit_exceeded",
"message": "Too many jobs running at once. This plan allows 8 concurrent jobs.",
"param": null,
"request_id": "req_01J8ZK3M9QX4HD2NCR",
"doc_url": "https://mlslabs.io/docs/errors.html#concurrency_limit_exceeded",
"retryable": true,
"details": {
"concurrency_limit": 8,
"running_jobs": 8
}
}
} Collection file: mls-api.postman_collection.json · import it into Postman and set the api_key collection variable.
Questions
Common questions about API errors
Does an invalid API key return 401 or 402?
401. invalid_api_key means the credential is wrong, so the fix is to rotate the key. insufficient_credits returns 402 because the credential is fine and the action is to top up. Mixing the two sends customers down the wrong path.
Are credits charged when a job fails?
No. Credits are pre-authorised on submit and settled only on success — a failed job refunds the full authorisation and reports charged_credits: 0 with refunded: true.
Is the error object the same for sync calls, async jobs and webhooks?
Yes. The same error object with the same fields is returned by the synchronous response, by GET /jobs/{id} when status is failed, and inside the job.failed webhook — so one handler covers all three.
Why did I get a different-looking error from the gateway?
Requests rejected at the API gateway — throttling or credential checks that never reach the backend — used to be serialised by the gateway itself. The gateway now rewrites those bodies into the same envelope, so every 401 and 429 is indistinguishable in shape from a backend error.
Handle failures once, everywhere
One envelope across sync calls, async jobs and webhooks — with credits refunded automatically when a job fails.