Errors
Every error code the API returns, what it means, and what to do about it.
Errors are a flat set of stable codes. The string identifies the kind of failure; the HTTP status tells you what class of fix it needs. Pick branches on code, not on the message — messages are for humans.
The envelope
Every 4xx and 5xx response carries the same shape:
{
"error": {
"code": "APPROVAL_REQUIRED",
"message": "Content container cnt_01HXZ9... is not approved.",
"requestId": "req_01HXZ9G7KMV2QX8Y1S5RJW3B7T",
"details": {
"containerId": "cnt_01HXZ9G7KMV2QX8Y1S5RJW3B7T",
"approvalStatus": "pending"
}
}
}codeis stable. We'll add new codes; we won't rename existing ones.messageis a human sentence. Log it, don't branch on it.requestIdappears on every response, error or not. Keep it in your logs.detailsis present when a code carries structured context. Fields documented per code below are the ones we guarantee.
Include requestId in every support ticket. It's the one field that lets us
find your exact request in the haystack.
Reading the table
We group by behavior, not by HTTP status:
- Caller bugs — your request is wrong. Fix the request; don't retry.
- Policy gates — we refused on purpose. Change state (approve the container, reconnect the account, ask us for a quota bump), then retry.
- Transient — something slipped. Retry with backoff.
- Upstream — a platform (TikTok, Meta, Apple) rejected us. Read
details.platformCodeand act on the platform's terms.
The "Retry?" column is advice, not a command. A PLATFORM_ERROR for an expired access token isn't worth retrying until you reconnect; a PLATFORM_ERROR for a flaky upload is.
Caller bugs — fix the request
| Code | HTTP | What happened | Retry? | What to check |
|---|---|---|---|---|
UNAUTHENTICATED | 401 | Missing, malformed, or wrong API key. | No | Authorization: Bearer lp_… header. Is it the current key? Has it been revoked? Run GET /v1/whoami to sanity-check. |
FORBIDDEN_SCOPE | 403 | The key is valid but missing the scope this route needs. details.requiredScope tells you which. | No | Create a new key with the right scope, or ask your Layers contact to widen an existing one. |
FORBIDDEN_FENCE | 403 | You sent a field that's gated behind a future release — for example, v2 engagement config fields before they ship. | No | Drop the fenced fields. See Versioning. |
NOT_FOUND | 404 | The resource doesn't exist or isn't in your org. We return 404 for both on purpose — we don't leak cross-org existence. | No | Verify the id, verify the project belongs to your org, verify customerExternalId if you set one. |
CONFLICT | 409 | State collision. A resource is already in the target state, or a body-id upsert would silently mutate an existing row. details.reason narrows it. | No | Change state or fix the body. See Idempotency for body-id upserts. |
IDEMPOTENCY_CONFLICT | 409 | Same Idempotency-Key was used earlier with a materially different request body. details.originalRequestHash and details.currentRequestHash let you diff the two. | No | Create a new idempotency key for the new body. See Idempotency. |
VALIDATION | 422 | The body parsed but failed schema or business-rule validation. details.issues[] lists every field that failed with a path and message. | No | Fix the fields called out in details.issues. |
Policy gates — change state, then retry
| Code | HTTP | What happened | Retry? | What to check |
|---|---|---|---|---|
APPROVAL_REQUIRED | 403 | Project has requires_approval: true and you tried to schedule or publish a container that isn't approved. | After approval | Approve the container via POST /v1/content/:containerId/approve or flip the project policy. |
CONTENT_REJECTED | 409 | Tried to schedule or publish a container whose approval_status is rejected. Rejected content can't be promoted — approval is a one-way gate once refused. | No | Call POST /v1/content/:containerId/regenerate with a revised brief, or create a fresh container. |
RETURN_URL_NOT_ALLOWED | 400 | OAuth returnUrl not on your key's allowlist. | No | Add the URL via ladmin, then create a fresh OAuth URL. |
KILL_SWITCH | 503 | Your key, your org, or the whole partner API has been flipped off. details.scope is one of key, organization, global. | Only on global after we un-flip | For key / organization, contact us. For global, wait and retry. |
BILLING_EXHAUSTED | 402 | Out of generation credits or ads wallet balance. details.resource tells you which. | After top-up | Top up the wallet or wait for the monthly credit grant. |
MODERATION_BLOCKED | 422 | Our safety layer refused to ship the generated asset. | Regenerate | Adjust the brief and regenerate. Don't loop. |
CREDENTIAL_INVALID | 422 | The platform credential we need expired or was revoked. details.platform + details.socialAccountId tell you what to reconnect. | After reconnect | Create a reauth URL and push the user through it. |
CIRCUIT_OPEN | 503 | A circuit breaker on one of our platform integrations is open (e.g., App Store scraping after repeated failures). details.resource + details.retryAfterSeconds tell you what and when. | After retryAfterSeconds | Wait out the cooldown. Retrying sooner will just extend it. |
Transient — retry with backoff
| Code | HTTP | What happened | Retry? | What to check |
|---|---|---|---|---|
RATE_LIMITED | 429 | Token bucket empty for (api_key_id, endpoint_class). Retry-After header and details.retryAfterMs tell you when. | Yes | Sleep at least Retry-After seconds. See Rate limits. |
INTERNAL | 500 | We broke. requestId lets us find it. | Yes, with jitter | Retry two or three times with exponential backoff. If it keeps failing, open a ticket with the requestId. |
Upstream — platform rejected us
PLATFORM_ERROR is a single code for every upstream failure. The platform lives in details.platform; the platform's own error code lives in details.platformCode; the raw message is details.platformMessage. The upstream is always exactly one of tiktok, meta, apple, instagram, appstore.
| Code | HTTP | What happened | Retry? | What to check |
|---|---|---|---|---|
PLATFORM_ERROR | 502 | Platform API returned an error. Act on details.platformCode. | Depends | If the platform returned a retryable code (rate limit, 5xx), retry with backoff. If it's a content/policy rejection, fix the content. |
Example:
{
"error": {
"code": "PLATFORM_ERROR",
"message": "TikTok rejected the upload: video too long.",
"requestId": "req_01HXZ9G7...",
"details": {
"platform": "tiktok",
"platformCode": "video_duration_exceeds_limit",
"platformMessage": "Video duration 185s exceeds max 180s for this account.",
"retryAfterMs": null
}
}
}retryAfterMs is populated when the platform hands us a retry window. When it's null, treat the error as non-transient until you've addressed the underlying issue.
Job failures
Jobs (async ops) use the same error shape inside the terminal GET /v1/jobs/:jobId response, with one wire-level difference: the structured-context field is named data (not details) on the nested job error, and there is no requestId on it — the outer HTTP response still carries X-Request-Id.
{
"status": "failed",
"finishedAt": "2026-04-18T14:22:10Z",
"error": {
"code": "MODERATION_BLOCKED",
"message": "Generated asset failed safety review.",
"data": { "stage": "finalizing", "safetyCategory": "violence" }
}
}The codes are the same table above — you don't learn a second vocabulary for jobs. The extra field is data.stage: the job stage we were on when it failed.
One additional code is specific to the job path: WORKFLOW_START_FAILED (500) — we accepted the POST and allocated the job row, but the underlying workflow start failed. Retry with the same Idempotency-Key.
Retry guidance
Safe defaults, if you're writing this once and forgetting it:
RATE_LIMITED— sleepRetry-Afterseconds exactly. Don't add jitter; the server already staggered your bucket reset.INTERNAL— exponential backoff starting at 1s, cap 30s, max 3 attempts.PLATFORM_ERRORwithretryAfterMs— honor it exactly, then retry once.PLATFORM_ERRORwithoutretryAfterMs— don't retry. Surface the error to your caller.CIRCUIT_OPEN— waitretryAfterSeconds, then make one request to see if the breaker closed. If it's still open, wait again.- Everything else in the "Caller bugs" and "Policy gates" tables — don't retry until you've fixed state.
Every retry you do should reuse the original Idempotency-Key. That's the whole point of idempotency — cached responses replay, the underlying work doesn't run twice. See Idempotency.