Every endpoint fails the same way: a non-2xx status and a JSON body with one error object. Branch on code, which is stable; show message to people, which is written to be read.
Error shape#
{
"error": {
"code": "insufficient_credits",
"message": "This needs 80 credits and you have 35. Presets, recolors, and exports stay free.",
"details": { "needed": 80, "balance": 35 }
}
}details is null unless there is something structured to add. For a body that fails validation it is an array of "path: message" strings, one per problem:
{
"error": {
"code": "invalid_request",
"message": "Request body is invalid.",
"details": ["prompt: Too small: expected string to have >=3 characters"]
}
}Spec, clip and canvas validation use the same field: "Spec is invalid.", "Clip is invalid." or "Canvas graph is invalid." with every problem listed in details.
Status codes#
| Status | Meaning |
|---|---|
| 200 · 201 | Done. 201 when something was created synchronously (a preset animation, an upload, a canvas, a key, a webhook, an imported spec). |
| 202 | Accepted: a job was started. The body carries the job (and, for POST /mascots, the new mascot). |
| 400 | The request is malformed or fails validation. |
| 401 | No API key or session, or the key is invalid or revoked, or the session expired. |
| 402 | Not enough credits for a model-backed action, or no free seat for an invite. |
| 403 | Your role doesn't allow this, an API key tried to manage people, a cross-site request was refused, or a share token is invalid. |
| 404 | The mascot, animation, job, file, canvas, member or invite doesn't exist in this workspace. |
| 409 | The mascot has no drawing yet, or a people change conflicts (already a member, already invited, the owner must transfer first). |
| 410 | An invite was already used, revoked or has expired. |
| 429 | Too many API-key requests this minute for your plan, or too many sign-in links requested. |
| 500 | Something broke on our side. Safe to retry. |
Error codes#
| Code | Status | When | What to do |
|---|---|---|---|
invalid_request | 400 | Validation failed: bad fields, unknown preset, template or palette token, unsupported upload, file over 15 MB, an animated export without an animation, an image_url that can't be fetched. | Read message and details, fix the request. Don't retry unchanged. |
invalid_json | 400 | The body isn't valid JSON. | Send JSON with Content-Type: application/json. |
unauthorized | 401 | Missing Authorization header or session, an invalid or revoked key, or an expired session. | Send Authorization: Bearer lr_sk_…, or sign in again. See Authentication. |
insufficient_credits | 402 | The action costs more credits than the balance. details: { needed, balance }. Batches check the total up front. | Wait for the monthly allowance, or change plan. See Credits. |
seat_limit | 402 | An invite would take more seats than the plan has. Pending invites count. details: { seats, used, members, pending }. | Remove a member or revoke an invite, or move to a plan with more seats. See Workspaces, roles and seats. |
forbidden | 403 | Your role in the workspace doesn't allow the action (members can't manage keys, webhooks or people; only the owner changes the plan), or a share token is invalid, expired or for a different mascot. | Ask an admin or the owner, or create a fresh share link with POST /mascots/{id}/share. |
session_required | 403 | An API key called an endpoint that manages people (invites, roles, transfers). Keys belong to the workspace, not to a person. | Do it from the studio, signed in. |
csrf_failed | 403 | A cookie-authenticated write came from another site. | Call the API from your own page's origin, or use an API key from your server. |
invite_email_mismatch | 403 | The signed-in person isn't the invited address. | Sign in with the address the invite was sent to, or ask for an invite to yours. |
already_member · already_invited · owner_must_transfer · owner_role · personal_workspace | 409 | The person is already in the workspace or has a pending invite; the owner tried to leave or be demoted; or a personal workspace was deleted on its own. | Resend the existing invite, transfer ownership first, or delete the account instead. |
invite_used · invite_revoked · invite_expired | 410 | The invite link can't be used any more. | Ask an admin for a new invite. |
not_found | 404 | The resource doesn't exist or belongs to another workspace. A private mascot's bundle or preview without a key or token also returns 404. | Check the id and the key's workspace. |
not_ready | 409 | The mascot is still generating, so it has no spec to animate, export or edit. | Wait for the creation job, then retry. |
rate_limited | 429 | More API-key requests in the last minute than the plan allows (details: { retry_after, limit }), or too many sign-in links for one address or network. | Wait Retry-After seconds, then retry. Higher plans allow more; see Credits & limits. |
internal | 500 | An unexpected server error. | Retry with backoff. |
Failed jobs#
A job that starts fine can still fail later. That isn't an HTTP error: the job reaches status: "failed" (or "canceled") with a human-readable error string, and your webhook receives job.failed or job.canceled.
{
"id": "job_…",
"object": "job",
"type": "generate",
"status": "failed",
"error": "Cost ceiling of $0.56 reached",
"cost_usd": 0.5612,
"result": null
}- Credits come back. Credits are held when a job starts and refunded automatically when it fails or is canceled.
- Cost ceiling. A job whose model spend passes its ceiling is stopped with
Cost ceiling of $… reached. See Holds, refunds and ceilings. - Server restarts. A job interrupted by a restart fails with
Interrupted by a server restart. Retry the request. - Model errors are surfaced as
AI service error: …. - A failed creation leaves the mascot with
status: "failed"and the reason inmascot.error. A failed edit keeps the previous version current.
Handling errors in the SDK#
The SDK throws LumorigError with status, code, message and details copied from the body. It adds three codes of its own:
| Code | Status | When |
|---|---|---|
http_error | any | The error body wasn't JSON, or an event stream couldn't be opened. |
job_failed | 500 | createAndWait, fromPromptAndWait or createAndDownload finished with a job that didn't succeed. message is the job's error. |
timeout | 408 | jobs.wait passed its timeoutMs (default 15 minutes). The job is still running. |
Example#
import { LumorigError } from "@lumorig/sdk";
async function withRetry<T>(fn: () => Promise<T>, tries = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
const retryable = err instanceof LumorigError && (err.status >= 500 || err.code === "not_ready");
if (!retryable || i === tries - 1) throw err;
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
}