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_7d18b9a1... is not approved.",
"requestId": "req_01HXZ9G7KMV2QX8Y1S5RJW3B7T",
"details": {
"containerId": "cnt_7d18b9a1-8b2c-4f3e-a4d5-6e7f8a9b0c1d",
"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 mutate an existing resource. details.reason narrows it. For sub-orgs: patching / suspending / resuming an archived child, allocating credits into an archived child, or a project migration blocked because a mapped project has an in-flight execution (the blocking project is named). | 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. For sub-org credit-config: a one-sided auto-refill change (only refillThreshold or only refillAmount) carries details.code: REFILL_REQUIRES_THRESHOLD_AND_AMOUNT — set both or clear both. | No | Fix the fields called out in details.issues. |
UPLOAD_INCOMPLETE | 409 | Direct-upload finalize arrived before every presigned PUT landed. The message names the missing storage key. | After the PUT | Complete every file's PUT, then call finalize again — finalize is idempotent. |
PAYLOAD_TOO_LARGE | 413 | An upload body exceeds its per-type byte cap — for content uploads: 100MB video / 30MB image; for app media: the per-kind cap. details.maxBytes names the limit. | No | Export a smaller file. For video, a standard social MP4 preset lands well under the cap. |
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/projects/:projectId/content again with a fresh hook to produce a new container. |
RETURN_URL_NOT_ALLOWED | 403 | OAuth returnUrl not on your key's allowlist. details.returnUrl and details.host (when the URL parsed) echo what you sent so you can confirm what was tested. | No | Add the URL to your allowed return URLs, then create a fresh OAuth URL. Contact your Layers account manager to update the key. |
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 credits. On a credit-spending call the body carries details.reason: "insufficient" (the wallet can't cover this generation) or "cap" (a sub-org child hit its monthly monthlyCreditCap ceiling). allocate also returns 402 when the parent wallet can't cover the transfer, but with no details. | After top-up | Top up the wallet or wait for the monthly credit grant. For a child cap (reason: "cap"), raise monthlyCreditCap via PATCH …/credit-config; for allocate, top up the parent first. |
MODERATION_BLOCKED | 422 | Our safety layer refused to ship the generated asset. | New call | Try again with a different hook. 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. |
UPLOAD_QUOTA_EXCEEDED | 409 | The project's uploaded-content quota is full. details always carries currentUploads, maxUploads, currentBytes, maxBytes — usage and limits, never a bare refusal. | After freeing quota | Delete uploaded content you no longer need, or contact support to raise the limits. |
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 | Honor Retry-After. See Rate limits. |
INTERNAL | 500 | We broke. requestId lets us find it. The message is intentionally generic ("An unexpected error occurred. Please contact support with the requestId.") — server-side details are logged against your requestId so we can find them, but they're never round-tripped to your response. | 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. |
SCRAPE_FAILED | 502 | A URL you gave us to fetch (app media, content upload) returned an error or timed out. Expired signed URLs land here too. | After fixing the URL | Confirm the URL is reachable from the public internet and, if signed, that the signature hasn't expired — sign for at least 15 minutes. |
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 exceeds the max 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
Async jobs 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" }
}
}Jobs use the same error vocabulary. The extra field is data.stage: the job stage active when the failure occurred.
One additional code is specific to the job path: WORKFLOW_START_FAILED (500). The request was accepted, but the job could not start. Retry with the same Idempotency-Key.
Retry guidance
Safe defaults, if you're writing this once and forgetting it:
RATE_LIMITED- honorRetry-After. Don't add jitter; the server already staggered your bucket reset.INTERNAL- exponential backoff with a small attempt cap.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.