Error handling
Every HTTP status code, error response, rate limit scenario, and async failure mode in the Luma Agents API.
This page documents every error the Luma Agents API can return, both synchronous errors (returned immediately) and asynchronous failures (discovered when polling). Use it as a reference when building error handling into your integration.
Quick reference
Section titled “Quick reference”| Status | Meaning | Retryable? |
|---|---|---|
| 201 | Success — generation created | — |
| 400 | Invalid request parameters | No — fix parameters |
| 401 | Invalid or missing API key | No — fix authentication |
| 402 | Insufficient balance | No — add funds |
| 403 | Access denied (suspended or deactivated client) | No — contact support |
| 413 | Input media exceeds size limit | No — resize media |
| 422 | Parameter combination rejected, or bad media data (base64 or URL) | No — fix request |
| 429 | Rate limited (RPM or concurrent) | Yes — use Retry-After header |
| 502 | Upstream service unavailable | Yes — retry with backoff |
| 503 | Image ingestion unavailable | Yes — retry or use base64 |
Machine-readable codes
Section titled “Machine-readable codes”Synchronous errors (returned immediately) use HTTP status codes as the primary machine-readable identifier — branch on the status code, then read detail for specifics. See the quick reference table above.
Asynchronous failures (discovered when polling) include a failure_code field for programmatic handling:
failure_code | Description | Retryable? |
|---|---|---|
content_moderated | Prompt or input image violated content guidelines | No — modify prompt |
generation_failed | Internal model error during generation | Yes — retry same request |
budget_exhausted | Ran out of funds mid-generation | No — add funds, then retry |
output_not_found | Generated output could not be retrieved | Yes — retry same request |
image_too_large | An input image exceeded the size limit (detected during processing) | No — resize/compress the input |
unsupported_format | An input media file was in an unsupported format | No — convert to a supported format |
corrupt_input | An input media file could not be decoded | No — re-encode or replace the input |
invalid_request | The request was rejected as invalid during generation | No — fix the request parameters |
rate_limited | An upstream provider rate-limited the generation | Yes — retry with backoff |
if generation.state == "failed": if generation.failure_code == "content_moderated": # Do not retry — modify the prompt raise ValueError(f"Content policy violation: {generation.failure_reason}") else: # Transient error — safe to retry retry_generation(generation)See Asynchronous failures below for the full response structure and handling examples.
Error response format
Section titled “Error response format”All error responses share the same shape:
{ "detail": "Human-readable error message describing what went wrong"}Every response — success or error — includes these headers:
| Header | Description |
|---|---|
X-Request-Id | Echoes your X-Request-Id header, or a server-generated UUID if you didn’t send one |
X-API-Version | API version for this response (currently 2026-04-01) |
Synchronous errors on POST /v1/generations
Section titled “Synchronous errors on POST /v1/generations”These errors are returned immediately when submitting a generation request.
201 — Created (success)
Section titled “201 — Created (success)”The happy path. The generation was accepted and queued for processing.
Request:
curl -X POST https://agents.lumalabs.ai/v1/generations \ -H "Authorization: Bearer $LUMA_AGENTS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "A glass of iced coffee on a marble countertop, morning light streaming through a window", "aspect_ratio": "16:9" }'Response (HTTP 201):
HTTP/1.1 201 CreatedContent-Type: application/jsonX-Request-Id: 550e8400-e29b-41d4-a716-446655440000X-API-Version: 2026-04-01X-RateLimit-Limit: 30X-RateLimit-Remaining: 28X-RateLimit-Reset: 1712592060{ "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "type": "image", "state": "queued", "model": "uni-1", "created_at": "2026-04-08T12:00:00Z", "output": [], "failure_reason": null, "failure_code": null}Note the rate limit headers — use them to track your remaining quota proactively.
400 — Bad Request
Section titled “400 — Bad Request”The request body contains invalid or conflicting parameters. The detail field tells you exactly what’s wrong.
Unknown model
Request:
{ "prompt": "A sunset over the ocean", "model": "super-model-v9"}Response (HTTP 400):
{ "detail": "Unknown model: super-model-v9"}Fix: Use a supported model. Supported values are uni-1, uni-1-max, and ray-3.2 — all available to all accounts. See Models for the capability matrix. Note: any future unreleased alias names will also surface as "Unknown model" (HTTP 400), not as a distinct “not on your plan” error — this is intentional, to prevent enumeration of unreleased aliases.
Unsupported generation type for this model
Each model accepts a specific set of type values. Crossing model and type returns HTTP 400 with the exact pair in the detail.
Example — type: "video" on an image model:
{ "prompt": "A sunset over the ocean", "type": "video" }{ "detail": "Type 'video' is not supported for model 'uni-1'" }Example — type: "image" on a video model:
{ "model": "ray-3.2", "type": "image", "prompt": "A sunset over the ocean" }{ "detail": "Type 'image' is not supported for model 'ray-3.2'" }Fix: Use a type value the model supports. uni-1 / uni-1-max accept image and image_edit. ray-3.2 accepts video, video_edit, and video_reframe. See Models for the full capability matrix.
source missing on video_edit or video_reframe
source is required for type: "video_edit" and type: "video_reframe". Provide a prior generation id as source.generation_id, a hosted video URL as source.url, or inline base64 video as source.data — the last two paired with a video/* source.media_type.
Request:
{ "model": "ray-3.2", "type": "video_edit", "prompt": "Transform the scene into moonlit film footage"}Response (HTTP 400):
{ "detail": "source is required for type 'video_edit' or 'video_reframe' — provide source.generation_id (UUID of a prior generation) or source.url / source.data with source.media_type='video/mp4'"}Fix: Pass the top-level id of a prior completed video generation as source.generation_id, or pass a hosted video as source.url / inline video as source.data with source.media_type: "video/mp4". See Video editing — source video.
source.generation_id set on the wrong request type
source.generation_id chains off a prior generation’s output — valid for type: "image_edit" (prior image), type: "video_edit", and type: "video_reframe" (prior video). It is rejected on type: "video". For video extend, put the prior generation id in video.start_frame.generation_id or video.end_frame.generation_id instead.
Request:
{ "model": "ray-3.2", "type": "video", "prompt": "A sunset over the ocean", "source": { "generation_id": "d290f1ee-6c54-4b01-90e6-d701748f0851" }}Response (HTTP 400):
{ "detail": "source.generation_id is only valid for type 'image_edit', 'video_edit', or 'video_reframe'"}Fix: Remove source, change type to "video_edit" / "video_reframe", or move the generation reference to a video keyframe for extend.
source.url or source.data on video_edit / video_reframe without a video media_type
A hosted source.url or inline source.data video is supported for type: "video_edit" and type: "video_reframe", but you must declare a video/* source.media_type so the route can dispatch video ingest before fetching any bytes. A missing or non-video/* value is rejected.
Request:
{ "model": "ray-3.2", "type": "video_edit", "prompt": "winter version", "source": { "url": "https://example.com/source.mp4" }}Response (HTTP 400):
{ "detail": "source.media_type must be a video/* type (e.g. 'video/mp4') when providing source.url / source.data on type='video_edit'"}Fix: Add source.media_type, for example "video/mp4". Source videos must be 30 seconds or shorter. See Video editing — source video.
image_ref set on video_edit
image_ref is image-only — video edits have no equivalent input channel. Setting it on video_edit is rejected.
Request:
{ "model": "ray-3.2", "type": "video_edit", "prompt": "winter version", "source": { "generation_id": "d290f1ee-6c54-4b01-90e6-d701748f0851" }, "image_ref": [{ "url": "https://example.com/style.jpg" }]}Response (HTTP 400):
{ "detail": "image_ref is not supported for type 'video_edit'"}Fix: Remove image_ref. Guide the edit through the prompt and video.edit (strength, auto_controls, or per-signal controls).
video.loop on a video_edit request
loop is create-only — valid for type: "video" only.
Request:
{ "model": "ray-3.2", "type": "video_edit", "prompt": "Transform the scene", "source": { "generation_id": "d290f1ee-6c54-4b01-90e6-d701748f0851" }, "video": { "loop": true }}Response (HTTP 400):
{ "detail": "video.loop is only valid for type 'video'"}Fix: Drop loop, or switch to type: "video".
video.edit on a video (create) request
The video.edit conditioning block is edit-only — valid for type: "video_edit" only.
Request:
{ "model": "ray-3.2", "type": "video", "prompt": "A sunset over the ocean", "video": { "edit": { "strength": "flex_2" } }}Response (HTTP 400):
{ "detail": "video.edit is only valid for type 'video_edit'"}Fix: Drop video.edit for generation, or set type: "video_edit" with a valid source video. See Video editing — edit controls.
video.edit.auto_controls: true combined with video.edit.controls
Auto mode derives the conditioning schedule from the source video, so it can’t be combined with manual per-signal controls. Pick one.
Request:
{ "model": "ray-3.2", "type": "video_edit", "prompt": "Transform the scene", "source": { "generation_id": "d290f1ee-6c54-4b01-90e6-d701748f0851" }, "video": { "edit": { "auto_controls": true, "controls": { "pose": { "enabled": true } } } }}Response (HTTP 400):
{ "detail": "video.edit.auto_controls=true cannot be combined with video.edit.controls (auto mode derives the schedule and ignores manual per-signal controls)"}Fix: Use auto_controls: true for a model-derived schedule, or drop auto_controls and supply strength / controls for manual mode. See Video editing — edit controls.
Invalid video.edit.keyframes / keyframe_indexes
video.edit.keyframes (guide-frame images) and video.edit.keyframe_indexes (their source-frame positions) are parallel arrays — each keyframes[i] is anchored at keyframe_indexes[i]. Several constraints are enforced, each returning HTTP 400:
| Mistake | detail |
|---|---|
| Only one of the two arrays provided | video.edit.keyframes and video.edit.keyframe_indexes must both be provided or both omitted |
| Arrays of different lengths | video.edit.keyframe_indexes must have the same length as video.edit.keyframes |
| A negative frame index | video.edit.keyframe_indexes must be non-negative |
| Duplicate frame indexes | video.edit.keyframe_indexes must be unique |
Fix: Provide both arrays at equal length, with non-negative, unique indexes. See Video editing — guide frames.
video.start_frame combined with video.edit.keyframes
A single video.start_frame and the multi-anchor video.edit.keyframes spell the same intent two ways (start_frame is equivalent to keyframes=[X] at keyframe_indexes=[0]), so they’re mutually exclusive. Setting both is rejected rather than silently merged.
Response (HTTP 400):
{ "detail": "video.start_frame and video.edit.keyframes are mutually exclusive — use video.start_frame for the single-anchor case (frame at index 0), or video.edit.keyframes + keyframe_indexes for multi-anchor / arbitrary positions"}Fix: Pick one — video.start_frame for a single anchor, or video.edit.keyframes + keyframe_indexes for multiple. See Video editing — guide frames.
video.source_position set on a non-reframe type
video.source_position controls where the source video sits inside the new canvas and is only meaningful when reframing.
Response (HTTP 400):
{ "detail": "video.source_position is only valid for type 'video_reframe'"}Fix: Drop video.source_position, or set type: "video_reframe" with a target aspect_ratio. See Video reframing — video.source_position.
Incompatible video option combinations
Some video options can’t be combined. Each combination is rejected with HTTP 400 and a specific detail:
{ "detail": "resolution '540p' is not supported with hdr=true" }{ "detail": "duration '10s' is not supported with hdr=true" }{ "detail": "duration '10s' is not supported with start_frame / end_frame" }{ "detail": "loop is not supported with duration '10s'" }{ "detail": "loop is not supported with hdr=true" }{ "detail": "loop is not supported with end_frame (no seamless start↔end match)"}{ "detail": "exr_export requires hdr=true" }Fix: Adjust the request so the options are compatible — HDR needs 720p/1080p and 5s; exr_export needs hdr: true; loop needs 5s, standard dynamic range, and no end_frame; 10s can’t be combined with anchor frames or HDR. See Video generation — parameters.
Video extend constraints
A type: "video" request with exactly one generation_id keyframe is a video extend request. Extend rejects HDR, and looping is only supported for forward extend.
{ "detail": "HDR is not supported with video extend" }{ "detail": "loop is only supported with forward video extend" }{ "detail": "generation-ref keyframes are only supported for video extend (a single start_frame OR end_frame generation reference); interpolate-from-generation is not yet available"}Fix: Use exactly one generation_id keyframe, drop HDR/EXR, and set loop: true only when the generation_id is in video.start_frame.
Video reframe-only constraints
type: "video_reframe" requires a target aspect_ratio and rejects controls that do not apply to reframe.
{ "detail": "aspect_ratio (the target shape) is required for type 'video_reframe'"}{ "detail": "video.start_frame / video.end_frame are not supported for type 'video_reframe'"}{ "detail": "video.loop is not supported for type 'video_reframe'" }{ "detail": "HDR / exr_export are not supported for type 'video_reframe'" }{ "detail": "video.edit is not supported for type 'video_reframe'" }1080p reframe works for landscape and square aspect ratios. Only vertical targets (9:16, 3:4) at 1080p are not yet available — those return:
{ "detail": "1080p resolution is not yet available for vertical reframe (aspect_ratio '9:16', type 'video_reframe') — coming soon. Use a landscape aspect_ratio at 1080p, or resolution '720p' / '540p'."}Fix: Provide aspect_ratio, keep video to resolution (and optional source_position) only, and reference a valid source video. For a vertical target, use 720p / 540p instead of 1080p. See Video reframing.
Invalid aspect ratio
Request:
{ "prompt": "A sunset over the ocean", "aspect_ratio": "4:3"}Response (HTTP 400):
{ "detail": "Invalid aspect_ratio '4:3'. Valid: 1:1, 1:2, 1:3, 16:9, 2:1, 2:3, 3:1, 3:2, 9:16"}Fix: Use one of the supported aspect ratios. The Valid: list is sorted lexicographically by the server and is model-and-type-dependent — it enumerates exactly what is accepted for the model and type you sent. Image models accept the nine ratios listed above; Ray 3.2 video accepts 9:16, 3:4, 1:1, 4:3, 16:9, 21:9. See Image generation — aspect_ratio and Video generation — aspect_ratio.
Prompt too short (empty)
Request:
{ "prompt": ""}Response (HTTP 400):
{ "detail": "Prompt must be between 1 and 6000 characters"}Fix: Provide a prompt with at least 1 character.
Prompt too long (over 6,000 characters)
Request:
{ "prompt": "A very long prompt that exceeds 6000 characters..."}Response (HTTP 400):
{ "detail": "Prompt must be between 1 and 6000 characters"}Fix: Shorten the prompt to 6,000 characters or fewer.
Malformed image reference — both url and data provided
Each ImageRef must use either url or data, not both.
Request:
{ "prompt": "A sunset over the ocean", "image_ref": [ { "url": "https://example.com/photo.jpg", "data": "iVBORw0KGgo...", "media_type": "image/jpeg" } ]}Response (HTTP 400):
{ "detail": "image_ref[0]: provide either 'url' or 'data', not both"}The detail is prefixed with the field path — source for the edit source image, or image_ref[i] for the reference at index i.
Fix: Remove either the url or the data/media_type fields.
Too many image references on an edit (more than 8)
For type: "image_edit", the source image occupies one reference slot, so you may only pass up to 8 additional references via image_ref.
Request:
{ "type": "image_edit", "prompt": "Restyle this scene", "source": { "url": "https://example.com/source.jpg" }, "image_ref": [ { "url": "https://example.com/1.jpg" }, { "url": "https://example.com/2.jpg" }, { "url": "https://example.com/3.jpg" }, { "url": "https://example.com/4.jpg" }, { "url": "https://example.com/5.jpg" }, { "url": "https://example.com/6.jpg" }, { "url": "https://example.com/7.jpg" }, { "url": "https://example.com/8.jpg" }, { "url": "https://example.com/9.jpg" } ]}Response (HTTP 400):
{ "detail": "image_edit supports up to 8 reference images (source occupies one slot)"}Fix: Reduce image_ref to 8 or fewer entries.
Source provided for type “image”
The source field is only valid for image_edit requests. If you provide it with type: "image" (or the default), the request is rejected.
Request:
{ "prompt": "A sunset over the ocean", "type": "image", "source": { "url": "https://example.com/photo.jpg" }}Response (HTTP 400):
{ "detail": "source is only valid for type 'image_edit', 'video_edit' / 'video_reframe' with source.generation_id, or 'video_edit' / 'video_reframe' with a video source.url / source.data"}Fix: Remove the source field, change type to "image_edit", or use the video source contract on type: "video_edit" / "video_reframe".
Source missing for type “image_edit”
The source field is required for image_edit requests.
Request:
{ "prompt": "Make the sky purple", "type": "image_edit"}Response (HTTP 400):
{ "detail": "source is required for type 'image_edit'"}Fix: Add a source object with either a url or data field.
Base64 data without media_type
When providing inline image data, media_type is required.
Request:
{ "prompt": "A sunset over the ocean", "image_ref": [ { "data": "iVBORw0KGgo..." } ]}Response (HTTP 400):
{ "detail": "image_ref[0]: 'media_type' is required with 'data'"}Fix: Add "media_type": "image/jpeg" (or the appropriate MIME type).
401 — Unauthorized
Section titled “401 — Unauthorized”Authentication failed. The Authorization header is missing, malformed, or contains an invalid key.
Missing Authorization header
Request:
curl -X POST https://agents.lumalabs.ai/v1/generations \ -H "Content-Type: application/json" \ -d '{"prompt": "A sunset"}'Response (HTTP 401):
{ "detail": "Missing or invalid API key"}Fix: Add -H "Authorization: Bearer $LUMA_AGENTS_API_KEY".
Malformed Authorization header (not Bearer scheme, or wrong key prefix)
The Authorization header must use the Bearer scheme and the token must be a luma-api-* key. If the scheme is wrong or the token doesn’t have the right prefix, the same "Missing or invalid API key" is returned.
Request:
curl -X POST https://agents.lumalabs.ai/v1/generations \ -H "Authorization: $LUMA_AGENTS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt": "A sunset"}'Response (HTTP 401):
{ "detail": "Missing or invalid API key"}Fix: Use Authorization: Bearer <key>, and confirm the key starts with luma-api-.
Invalid API key (wrong value, lookup failed)
The Authorization: Bearer ... header is well-formed, but the key value doesn’t match any active key on the server.
Response (HTTP 401):
{ "detail": "Invalid API key"}Fix: Check that you copied the full key, and that it hasn’t been replaced. Generate a new one if needed.
Revoked API key
The key was previously valid but has been explicitly revoked from the dashboard.
Request:
curl -X POST https://agents.lumalabs.ai/v1/generations \ -H "Authorization: Bearer luma-api-revoked_key_12345" \ -H "Content-Type: application/json" \ -d '{"prompt": "A sunset"}'Response (HTTP 401):
{ "detail": "API key has been revoked"}Fix: Generate a new API key and update your LUMA_AGENTS_API_KEY environment variable. The old key cannot be un-revoked.
Expired API key
API keys can have an expiration date set when they are created. Once past that date, the key returns 401 even if it has not been revoked.
Response (HTTP 401):
{ "detail": "API key has expired"}Fix: Generate a new API key.
402 — Payment Required
Section titled “402 — Payment Required”Your account does not have enough funds to process this generation.
Response (HTTP 402):
{ "detail": "Insufficient balance. Please add funds to continue."}Fix: Add funds to your account from the dashboard, then retry. The request was not queued. See Pricing for current rates.
403 — Forbidden
Section titled “403 — Forbidden”Access is denied. There are several reasons this can happen:
API client is suspended
Your API client has been suspended, typically due to a policy violation or billing issue.
Response (HTTP 403):
{ "detail": "API client is suspended"}Fix: Contact support to resolve the suspension.
API client is deactivated
Your API client has been deactivated.
Response (HTTP 403):
{ "detail": "API client is deactivated"}Fix: Contact support to reactivate the client.
413 — Payload Too Large
Section titled “413 — Payload Too Large”An input media reference in your request exceeds the size limit.
Response (HTTP 413): the detail names the offending field and the limit it crossed — either the 50 MB byte cap or the per-side pixel cap:
{ "detail": "source: image exceeds 50 MB limit" }{ "detail": "image_ref[2]: image exceeds 50 MB limit" }{ "detail": "source: image is 9000x6000px but each side must be <= 8000px. Resize and try again." }This applies to:
- The
sourceimage inimage_editrequests - Any entry in the
image_refarray - The
video.start_frameandvideo.end_frameanchor images on Ray 3.2 video requests - Inline
source.datavideo onvideo_editorvideo_reframe - Both URL-referenced and base64-encoded media
source.generation_id references an existing Luma generation by ID — the request body itself does not transport video bytes, so there is no 413 path at submit time for that source shape.
Fix: Resize or compress the input media before sending. Inline source videos for edit and reframe must also be 30 seconds or shorter.
422 — Unprocessable Entity
Section titled “422 — Unprocessable Entity”The request was syntactically valid (well-formed JSON, every field has the right type) but the body violates a semantic rule. There are two distinct families:
- Parameter-combination rejections — a value is allowed in isolation but not in combination with another field on the same request. Today the only such rule is
style: "manga"+ non-portraitaspect_ratio(see below). More may be added as new style presets ship; the response shape is identical. - Media-reference processing failures —
source, animage_refentry, or avideo.start_frame/video.end_framereference is well-formed but couldn’t be loaded (bad base64, unreachable URL, wrong content-type).
Manga style with a non-portrait aspect ratio
style: "manga" is portrait-only. Pairing it with a landscape or square aspect_ratio (3:1, 2:1, 16:9, 3:2, 1:1) is rejected with HTTP 422; the detail includes the list of valid portrait ratios so you can correct the request.
Request:
{ "prompt": "A warrior standing at the edge of a cliff", "style": "manga", "aspect_ratio": "1:1"}Response (HTTP 422):
{ "detail": "aspect_ratio='1:1' is not allowed when style='manga'. Valid aspect_ratio: 1:2, 1:3, 2:3, 9:16"}The detail format is:
aspect_ratio='<value-you-sent>' is not allowed when style='manga'. Valid aspect_ratio: 1:2, 1:3, 2:3, 9:16The Valid aspect_ratio: list is sorted lexicographically and enumerates the four portrait ratios accepted with style: "manga" — 2:3, 9:16, 1:2, 1:3. Branch on the 422 status code; treat the detail string as human-readable rather than parsing it programmatically.
Fix: Use one of the portrait ratios listed in the response, or omit aspect_ratio and let the model pick one.
Invalid base64 data
The data field contains a string that is not valid base64 encoding.
Request:
{ "prompt": "A sunset", "image_ref": [ { "data": "not-valid-base64!!!", "media_type": "image/png" } ]}Response (HTTP 422):
{ "detail": "image_ref[0]: invalid base64 data"}Fix: Ensure the data is properly base64-encoded. In Python: base64.b64encode(image_bytes).decode("utf-8").
URL fetch failure — unreachable host
The URL in url could not be reached.
Request:
{ "prompt": "A sunset", "image_ref": [ { "url": "https://nonexistent-domain-12345.com/image.jpg" } ]}Response (HTTP 422):
{ "detail": "image_ref[0]: failed to fetch URL"}Fix: Verify the URL is publicly accessible and responds with image content.
URL fetch failure — 404 or other HTTP error
The URL returned an HTTP error when fetched.
Request:
{ "type": "image_edit", "prompt": "A sunset", "source": { "url": "https://example.com/deleted-image.jpg" }}Response (HTTP 422):
{ "detail": "source: failed to fetch URL (HTTP 404)"}Fix: Confirm the URL returns a 200 response with valid image content. Test by opening it in a browser or fetching it with curl.
URL fetch failure — not an image
The URL returned successfully but the content is not a valid image.
Request:
{ "prompt": "A sunset", "image_ref": [ { "url": "https://example.com/document.pdf" } ]}Response (HTTP 422):
{ "detail": "image_ref[0]: content-type is not an image"}Fix: Ensure the URL points to a valid image file (JPEG, PNG, etc.). The detail is surfaced by the upstream fetch proxy, so exact wording may vary.
429 — Too Many Requests
Section titled “429 — Too Many Requests”You’ve hit a rate limit. There are two distinct scenarios:
Requests-per-minute (RPM) limit exceeded
You’ve sent too many requests in the current 60-second sliding window.
Response (HTTP 429):
HTTP/1.1 429 Too Many RequestsRetry-After: 12X-RateLimit-Limit: 30X-RateLimit-Remaining: 0X-RateLimit-Reset: 1712592012X-Request-Id: 550e8400-e29b-41d4-a716-446655440000X-API-Version: 2026-04-01{ "detail": "Rate limit exceeded"}Headers explained:
| Header | Example | Meaning |
|---|---|---|
Retry-After | 12 | Seconds until the oldest request in the window ages out (minimum 1) |
X-RateLimit-Limit | 30 | Your RPM allowance for the rolling 60-second window (example value — yours depends on your plan) |
X-RateLimit-Remaining | 0 | You have 0 requests remaining |
X-RateLimit-Reset | 1712592012 | Unix timestamp when the oldest request ages out |
Fix: Wait for the duration specified in Retry-After, or until the X-RateLimit-Reset timestamp.
Concurrent job limit exceeded
You have too many generations running simultaneously. Your concurrent-job allowance depends on your plan — check your Luma platform dashboard for the exact ceiling.
Response (HTTP 429):
HTTP/1.1 429 Too Many RequestsRetry-After: 60X-Request-Id: 661f9500-f30c-52e5-b827-557766551111X-API-Version: 2026-04-01{ "detail": "Too many concurrent jobs"}Note: Concurrent job 429s include a fixed Retry-After: 60 but do not include X-RateLimit-* headers (those only apply to RPM limiting).
Fix: Wait for one or more of your active generations to reach completed or failed before submitting new ones.
Retry strategy for 429 errors:
import randomimport timefrom luma_agents import RateLimitError
def create_with_retry(client, max_retries=5, **kwargs): for attempt in range(max_retries): try: return client.generations.create(**kwargs) except RateLimitError as e: if attempt == max_retries - 1: raise
# Prefer Retry-After; fall back to exponential backoff with jitter retry_after = e.response.headers.get("Retry-After") wait = int(retry_after) if retry_after else 2 ** attempt + random.uniform(0, 1)
print(f"Rate limited. Retrying in {wait:.1f}s (attempt {attempt + 1}/{max_retries})") time.sleep(wait)import Luma, { RateLimitError } from "luma-agents";
async function createWithRetry( client: Luma, params: Luma.GenerationCreateParams, maxRetries = 5,) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await client.generations.create(params); } catch (e) { if (!(e instanceof RateLimitError) || attempt === maxRetries - 1) throw e;
const retryAfter = e.headers?.get("retry-after"); const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : 2 ** attempt * 1000 + Math.random() * 1000;
console.log( `Rate limited. Retrying in ${(wait / 1000).toFixed(1)}s (attempt ${attempt + 1}/${maxRetries})`, ); await new Promise((r) => setTimeout(r, wait)); } }}func createWithRetry(ctx context.Context, client *lumaagents.Client, params lumaagents.GenerationNewParams, maxRetries int) (*lumaagents.Generation, error) { for attempt := 0; attempt < maxRetries; attempt++ { generation, err := client.Generations.New(ctx, params) if err == nil { return generation, nil }
var apiErr *lumaagents.Error if !errors.As(err, &apiErr) || apiErr.StatusCode != 429 { return nil, err } if attempt == maxRetries-1 { return nil, err }
retryAfter := apiErr.Response.Header.Get("Retry-After") var wait time.Duration if retryAfter != "" { secs, _ := strconv.Atoi(retryAfter) wait = time.Duration(secs) * time.Second } else { wait = time.Duration(1<<attempt)*time.Second + time.Duration(rand.Intn(1000))*time.Millisecond }
fmt.Printf("Rate limited. Retrying in %s (attempt %d/%d)\n", wait, attempt+1, maxRetries) time.Sleep(wait) } return nil, fmt.Errorf("max retries exceeded")}502 — Bad Gateway
Section titled “502 — Bad Gateway”An upstream service required to process your request is temporarily unavailable.
Response (HTTP 502): the detail names the field whose fetch failed:
{ "detail": "source: fetch proxy unavailable" }This happens when the fetch proxy (used to download URL-referenced media) is temporarily down.
Fix: Retry after a brief delay (5–10 seconds). If the error persists, check the status page or contact support.
503 — Service Unavailable
Section titled “503 — Service Unavailable”The image URL ingestion subsystem is not available.
Response (HTTP 503):
{ "detail": "Media URL ingestion is unavailable (fetch proxy not configured)"}This specifically affects requests that reference media via url (in source or image_ref).
Fix: As a workaround, download the image yourself and send it as base64 data instead:
import base64import httpx
# Download the image yourselfresponse = httpx.get("https://example.com/reference.jpg")image_b64 = base64.b64encode(response.content).decode("utf-8")
# Send as base64 instead of URLgeneration = client.generations.create( prompt="A similar scene but at sunset", image_ref=[ { "data": image_b64, "media_type": "image/jpeg", } ],)If you don’t need image references, retry without them.
Synchronous errors on GET /v1/generations/{generation_id}
Section titled “Synchronous errors on GET /v1/generations/{generation_id}”These errors are returned when polling for generation status.
200 — OK (success)
Section titled “200 — OK (success)”The generation was found and its current status is returned.
Request:
curl https://agents.lumalabs.ai/v1/generations/d290f1ee-6c54-4b01-90e6-d701748f0851 \ -H "Authorization: Bearer $LUMA_AGENTS_API_KEY"Response (HTTP 200) — still processing:
{ "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "type": "image", "state": "processing", "model": "uni-1", "created_at": "2026-04-08T12:00:00Z", "output": [], "failure_reason": null, "failure_code": null}Response (HTTP 200) — completed:
{ "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "type": "image", "state": "completed", "model": "uni-1", "created_at": "2026-04-08T12:00:00Z", "output": [ { "type": "image", "url": "https://storage.example.com/generations/d290f1ee/output.png?X-Amz-Expires=3600&..." } ], "failure_reason": null, "failure_code": null}401 — Unauthorized
Section titled “401 — Unauthorized”Same as the POST endpoint — the Authorization header is missing, malformed, or invalid.
Response (HTTP 401):
{ "detail": "Missing or invalid API key"}404 — Not Found
Section titled “404 — Not Found”The generation ID does not exist, or it belongs to a different API key.
Generation does not exist
Request:
curl https://agents.lumalabs.ai/v1/generations/00000000-0000-0000-0000-000000000000 \ -H "Authorization: Bearer $LUMA_AGENTS_API_KEY"Response (HTTP 404):
{ "detail": "Generation not found"}Fix: Verify the generation ID. It should be the id field from the POST /v1/generations response.
Generation belongs to another API key
Generations are scoped to the API key that created them. You cannot access another key’s generations.
Response (HTTP 404):
{ "detail": "Generation not found"}The error message is intentionally identical to “does not exist” to prevent enumeration.
Fix: Ensure you’re using the same API key that submitted the original generation.
Invalid generation ID format
The generation_id path parameter must be a valid UUID.
Request:
curl https://agents.lumalabs.ai/v1/generations/not-a-uuid \ -H "Authorization: Bearer $LUMA_AGENTS_API_KEY"Response (HTTP 404):
{ "detail": "Generation not found"}Fix: Use the UUID returned by POST /v1/generations.
Asynchronous failures
Section titled “Asynchronous failures”These failures happen after the POST succeeds with HTTP 201. You discover them by polling GET /v1/generations/{id} and finding state: "failed".
Failed response structure
Section titled “Failed response structure”{ "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "type": "image", "state": "failed", "model": "uni-1", "created_at": "2026-04-08T12:00:00Z", "output": [], "failure_reason": "Description of what went wrong", "failure_code": "generation_failed"}Key differences from a completed response:
stateis"failed"instead of"completed"outputis an empty array[]failure_reasoncontains a non-null human-readable stringfailure_codecontains a machine-readable code for programmatic handling
Failure codes
Section titled “Failure codes”The failure_code field provides a machine-readable code you can use in branching logic:
failure_code | Description | Action |
|---|---|---|
content_moderated | The prompt or input image violated content guidelines | Modify the prompt and resubmit — do not retry as-is |
generation_failed | The model encountered an internal error during generation | Retry the same request — this is typically transient |
budget_exhausted | The account ran out of funds partway through the generation | Add funds, then resubmit |
output_not_found | The generated output could not be retrieved | Retry the same request |
image_too_large | An input image exceeded the size limit, detected during processing rather than at submit | Resize or compress the input image, then resubmit — do not retry as-is |
unsupported_format | An input media file was in a format the model can’t process | Convert the input to a supported format, then resubmit |
corrupt_input | An input media file could not be decoded | Re-encode or replace the input, then resubmit |
invalid_request | The request was rejected as invalid during generation | Fix the request parameters — do not retry as-is |
rate_limited | An upstream provider rate-limited the generation | Retry the same request with exponential backoff |
Possible failure reasons
Section titled “Possible failure reasons”The failure_reason field provides a human-readable description. Common values:
| Failure reason | Description | Action |
|---|---|---|
| Generation output was flagged by our content moderation system. | The output image was flagged by content moderation | Modify the prompt and resubmit |
| Insufficient balance to complete this generation. | The account ran out of funds mid-generation | Add funds, then resubmit |
| Generation failed | The model encountered an internal error during generation | Retry the same request — this is typically transient |
| Output artifacts not found | The generated output could not be retrieved | Retry the same request |
Handling async failures in code
Section titled “Handling async failures in code”Always check for the failed state when polling. Never assume a generation will complete.
import timefrom luma_agents import Luma
client = Luma()
generation = client.generations.create( prompt="A sunset over the ocean",)
while generation.state not in ("completed", "failed"): time.sleep(2) generation = client.generations.get(generation.id)
if generation.state == "failed": print(f"Generation {generation.id} failed: {generation.failure_reason}") # Use failure_code for programmatic branching if generation.failure_code == "content_moderated": print("Content policy violation — do not retry with the same prompt") else: print("Transient error — safe to retry")elif generation.state == "completed": print(f"Success: {generation.output[0].url}")let generation = await client.generations.create({ prompt: "A sunset over the ocean",});
while (generation.state !== "completed" && generation.state !== "failed") { await new Promise((r) => setTimeout(r, 2000)); generation = await client.generations.get(generation.id);}
if (generation.state === "failed") { console.error(`Generation ${generation.id} failed: ${generation.failure_reason}`); // Use failure_code for programmatic branching if (generation.failure_code === "content_moderated") { throw new Error("Content policy violation — do not retry with the same prompt"); } else { console.log("Transient error — safe to retry"); }} else { console.log(`Success: ${generation.output![0].url}`);}func generateAndHandle(ctx context.Context, client *lumaagents.Client) error { generation, err := client.Generations.New(ctx, lumaagents.GenerationNewParams{ Prompt: lumaagents.F("A sunset over the ocean"), }) if err != nil { return fmt.Errorf("submit: %w", err) }
for generation.State != lumaagents.GenerationStateCompleted && generation.State != lumaagents.GenerationStateFailed { time.Sleep(2 * time.Second) generation, err = client.Generations.Get(ctx, generation.ID) if err != nil { return fmt.Errorf("poll: %w", err) } }
if generation.State == lumaagents.GenerationStateFailed { // Use FailureCode for programmatic branching if generation.FailureCode == "content_moderated" { return fmt.Errorf("content policy violation — do not retry with the same prompt: %s", generation.FailureReason) } return fmt.Errorf("transient failure — safe to retry: %s", generation.FailureReason) }
fmt.Printf("Success: %s\n", generation.Output[0].URL) return nil}Presigned URL expiry
Section titled “Presigned URL expiry”Output URLs in the output array are presigned S3 URLs that expire after 1 hour. This is not an HTTP error from the API, but a common failure mode in downstream code.
What happens when a URL expires
Section titled “What happens when a URL expires”curl -I "https://storage.example.com/generations/d290f1ee/output.png?X-Amz-Expires=3600&..."HTTP/1.1 403 ForbiddenThe S3 presigned URL returns a 403 — the image data is still there, but the signature has expired.
How to get a fresh URL
Section titled “How to get a fresh URL”Poll the generation endpoint again. Each call to GET /v1/generations/{id} returns a fresh presigned URL with a new 1-hour expiry:
# Original URL expiredgeneration = client.generations.get("d290f1ee-6c54-4b01-90e6-d701748f0851")fresh_url = generation.output[0].url # New presigned URL, valid for 1 hourBest practices for URL handling
Section titled “Best practices for URL handling”- Download immediately — Save images to your own storage as soon as the generation completes
- Don’t cache URLs — Presigned URLs are ephemeral; cache the downloaded image data instead
- Don’t expose URLs to end users — They’ll break after 1 hour; serve images from your own CDN
- Re-poll if needed — If you need the image again after expiry, call GET to get a new URL
Rate limiting
Section titled “Rate limiting”For the complete guide on rate limiting — including the sliding window algorithm, all response headers, retry strategies, and proactive throttling — see Rate limits and headers.
Quick reference:
| Limit | 429 detail |
|---|---|
| Requests per minute (RPM) | "Rate limit exceeded" |
| Concurrent jobs | "Too many concurrent jobs" |
Specific allowances depend on your plan — see your usage dashboard or the X-RateLimit-Limit header on a successful POST.
Request tracing
Section titled “Request tracing”Include X-Request-Id in your requests to correlate them with responses and support inquiries. The server echoes your value back, or generates a UUID if you don’t provide one. See Rate limits and headers — Request tracing for details.
Error handling checklist
Section titled “Error handling checklist”Use this checklist to verify your integration handles all failure modes:
| Scenario | Type | How to detect | Action |
|---|---|---|---|
| Successful generation | Happy path | HTTP 201, then poll until state: "completed" | Download from output[].url |
| Validation error | Sync | HTTP 400 | Fix request parameters, do not retry as-is |
| Auth failure | Sync | HTTP 401 | Check API key — missing, malformed, revoked, or expired |
| No funds | Sync | HTTP 402 | Add funds |
| Plan restriction | Sync | HTTP 403 | Upgrade plan, change model, or contact support (suspended/deactivated) |
| Input media too large | Sync | HTTP 413 | Compress image to under 50 MB |
| Incompatible parameters or bad media data | Sync | HTTP 422 | Fix the parameter combination (e.g. style+aspect_ratio) or fix the base64/URL |
| Rate limited | Sync | HTTP 429 | Wait for Retry-After seconds, then retry |
| Upstream down | Sync | HTTP 502 | Retry after 5–10 seconds |
| Ingestion down | Sync | HTTP 503 | Use base64 instead of URL, or retry later |
| Poll — not found | Sync | HTTP 404 on GET | Check generation ID and API key |
| Async failure | Async | state: "failed" on poll | Branch on failure_code, read failure_reason for details, retry if transient |
| Expired output URL | Downstream | HTTP 403 from S3 | Re-poll GET to get fresh presigned URL |
| Generation still running | Expected | state: "queued" or "processing" on poll | Continue polling every 2–5 seconds |