# Errors (/docs/api/operational/errors)



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 [#the-envelope]

Every 4xx and 5xx response carries the same shape:

```json
{
  "error": {
    "code": "APPROVAL_REQUIRED",
    "message": "Content container cnt_01HXZ9... is not approved.",
    "requestId": "req_01HXZ9G7KMV2QX8Y1S5RJW3B7T",
    "details": {
      "containerId": "cnt_01HXZ9G7KMV2QX8Y1S5RJW3B7T",
      "approvalStatus": "pending"
    }
  }
}
```

* `code` is stable. We'll add new codes; we won't rename existing ones.
* `message` is a human sentence. Log it, don't branch on it.
* `requestId` appears on every response, error or not. Keep it in your logs.
* `details` is present when a code carries structured context. Fields documented per code below are the ones we guarantee.

<Callout type="info">
  Include `requestId` in every support ticket. It's the one field that lets us
  find your exact request in the haystack.
</Callout>

## Reading the table [#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.platformCode` and 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 [#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`](/docs/api/reference/organizations/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](/docs/api/operational/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](/docs/api/operational/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](/docs/api/operational/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 [#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`](/docs/api/reference/approval/approve-content) 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`](/docs/api/reference/content/regenerate-content) 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](/docs/api/reference/social-accounts/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 [#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](/docs/api/operational/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 [#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:

```json
{
  "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 [#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`.

```json
{
  "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](/docs/api/concepts/jobs) 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 [#retry-guidance]

Safe defaults, if you're writing this once and forgetting it:

* `RATE_LIMITED` — sleep `Retry-After` seconds exactly. Don't add jitter; the server already staggered your bucket reset.
* `INTERNAL` — exponential backoff starting at 1s, cap 30s, max 3 attempts.
* `PLATFORM_ERROR` with `retryAfterMs` — honor it exactly, then retry once.
* `PLATFORM_ERROR` without `retryAfterMs` — don't retry. Surface the error to your caller.
* `CIRCUIT_OPEN` — wait `retryAfterSeconds`, 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](/docs/api/operational/idempotency).

## See also [#see-also]

* [Rate limits](/docs/api/operational/rate-limits)
* [Idempotency](/docs/api/operational/idempotency)
* [Jobs](/docs/api/concepts/jobs)
