> ## Documentation Index
> Fetch the complete documentation index at: https://api.simkl.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and status codes

> Every HTTP status code Simkl returns, what it means, and how to handle it.

Simkl uses standard HTTP status codes. Almost every error body carries `error` (machine-readable identifier), `code` (integer — the HTTP status code echoed in the body), and an optional `message` (human-readable guidance).

```json title="Standard error envelope" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "client_id_failed",
  "code":    412,
  "message": "Your client_id is wrong. Try another one"
}
```

<a id="envelopes" />

<Warning>
  **The `/oauth2/*` endpoints use a different envelope.** They follow [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) instead: an `error` plus an optional `error_description`, and **no `code` field** — the HTTP status carries that on its own.

  ```json title="RFC 6749 envelope — /oauth2/token, /oauth2/device, /oauth2/revoke" theme={"theme":{"light":"github-light","dark":"vesper"}}
  {
    "error":             "invalid_grant",
    "error_description": "PKCE verification failed"
  }
  ```

  This is what stock OAuth libraries expect, which is the point. It applies only to paths beginning `/oauth2/` — an [AUTH V2](/api-reference/auth-v2) token used on a normal API endpoint still gets the standard envelope above. If your error handler reads `code` from the body, it will find nothing on these four endpoints; read the HTTP status instead.

  Full list: [OAuth 2.0 errors](#oauth2-errors).
</Warning>

<Warning>
  **Branch on `error`, never on `message`.** The `error` identifier is the contract — it's stable across releases. The `message` text is developer-facing prose that may be reworded at any time. Parsing `message` will break your client.
</Warning>

## At a glance

| Code | Name | Retry? |
| - | - | - |
| [`200`](#ok) | OK | — |
| [`201`](#created) | Created | — |
| [`204`](#no-content) | No Content | — |
| [`302`](#found) | Found | — |
| [`400`](#bad-request) | Bad Request | No — fix the request |
| [`401`](#unauthorized) | Unauthorized | No — re-authenticate |
| [`403`](#forbidden) | Forbidden | No — fix the app or contact us |
| [`404`](#not-found) | Not Found | No |
| [`409`](#conflict) | Conflict | No |
| [`412`](#client-id-failed) | client\_id failed | No |
| [`429`](#too-many-requests) | Too Many Requests | Yes — back off |
| [`500`](#internal-server-error) | Internal Server Error | Yes — exponential backoff |
| [`502`](#bad-gateway) | Bad Gateway | Yes — exponential backoff |
| [`503`](#service-unavailable) | Service Unavailable | Yes — exponential backoff |

<Note>
  **Getting a token is scored separately.** The four `/oauth2/*` endpoints return their own OAuth-standard error vocabulary in a different envelope — including three values in the device flow that are not failures at all. See [OAuth 2.0 errors](#oauth2-errors).
</Note>

<Note>
  **Not every endpoint can return every code.** Each endpoint's reference page lists the codes that actually fire there — many endpoints can only return a subset (e.g., `/sync/activities` can only return `200`, `401`, `412`, `429`, `500`). The 4xx/5xx pages below describe the codes; check the per-endpoint reference for which apply.
</Note>

## Success codes

### OK

`200` — Success. The body contains the resource you requested.

### Created

`201` — Success. A new resource was created. Typical for `POST /sync/...` and `POST /scrobble/...`.

### No Content

`204` — Success but the response body is empty. Typical for `DELETE` endpoints.

### Found

`302` — A redirect. Follow the `Location` header. Most commonly returned by [the redirect endpoint](/api-reference/simkl/redirect).

## 4xx — your request

### Bad Request

`400` — The request was malformed. The `message` field usually identifies the offending parameter.

**Real `error` values returned**

| `error` | When | Example `message` |
| - | - | - |
| `empty_field` | A required field is missing | `Missed "to" parameter` |
| `wrong_parameter` | A field has an unaccepted value | `Wrong "to" parameter` |
| [`unauthorized_client`](#unauthorized-client) | A V2 `client_id` was sent to an AUTH V1 endpoint | `This client_id is an OAuth 2.0 app: use POST /oauth2/token` |

**Fix**
Read `message`, correct the request, then resend. Don't retry without changing the request.

```json title="400 Bad Request" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "empty_field",
  "code":    400,
  "message": "Missed \"to\" parameter"
}
```

<a id="unauthorized-client" />

#### `unauthorized_client` — a V2 app on a V1 endpoint

```json title="400 Bad Request" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "unauthorized_client",
  "code":    400,
  "message": "This client_id is an OAuth 2.0 app: use POST /oauth2/device instead of /oauth/pin"
}
```

**Your `client_id` is registered for AUTH V2, and you sent it to an [AUTH V1](/api-reference/auth-v1) endpoint.** It is returned by `GET /oauth/pin`, `POST /oauth/token`, `GET /oauth/authorize` and the account-link endpoint, and the `message` names the V2 endpoint to use instead.

This is the exact mirror of [`oauth2_token_required`](#oauth2-token-required): that one is a V1 *token* on a V2 endpoint, this one is a V2 *client ID* on a V1 endpoint. A V2 registration is V2-only, in both directions — there is no mixed mode.

<Note>
  **If you are [running both registrations side by side](/guides/migrating-v1-to-v2#run-both-client-ids), this is the error that tells you they got crossed.** Each `client_id` must go to its own endpoints: V1 credentials to `/oauth/*`, V2 credentials to `/oauth2/*`. The usual cause is a shared config that resolves one `client_id` while the flow code still points at the other version's URLs.
</Note>

### Unauthorized

`401` — Missing or invalid user access token.

**Common causes**

* `Authorization` header missing on a token-required endpoint.
* The user's `access_token` was revoked at [Connected Apps settings](https://simkl.com/settings/connected-apps/).
* A typo or truncation in the token value.

**Fix**
Re-authenticate. What that means depends on which auth version your app uses.

For [AUTH V1](/api-reference/auth-v1), tokens advertise `expires_in: 157680000` (5 years) on mint, so a 401 in practice means the user removed your app from their account, or the token never matched in the first place. Send them through the flow again.

For [AUTH V2](/api-reference/auth-v2), access tokens last 7 days, so a 401 is usually just expiry. **Try the [refresh grant](/api-reference/oauth2-tokens) first** and only re-authorize if the refresh also fails. Sending a user through consent for a routine expiry is avoidable.

```json title="401 Unauthorized" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error": "user_token_failed",
  "code":  401
}
```

The 401 response also carries an RFC 6750 §3 `WWW-Authenticate` header for clients that key off it:

```http theme={"theme":{"light":"github-light","dark":"vesper"}}
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer realm="api.simkl.com", error="user_token_failed"
```

<Note>
  **[AUTH V2](/api-reference/auth-v2) tokens report `invalid_token` rather than `user_token_failed`** when the access token is unrecognised or past its 7-day expiry. Handle both — which one you see depends on the token's generation, not on what went wrong.

  ```json title="401 Unauthorized" theme={"theme":{"light":"github-light","dark":"vesper"}}
  {
    "error":   "invalid_token",
    "code":    401,
    "message": "Access token has expired"
  }
  ```

  Note that the `WWW-Authenticate` challenge above is sent on normal API endpoints only. A 401 from `/oauth2/*` itself does **not** carry a Bearer challenge — there is no bearer token involved in getting one. The exception is a failed `client_secret` sent via HTTP Basic, which answers with `WWW-Authenticate: Basic realm="oauth2"`.
</Note>

<a id="user-token-required" />

#### `user_token_required` — a different 401

```json title="401 Unauthorized" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "user_token_required",
  "code":    401,
  "message": "This client_id requires a user access token on every request"
}
```

This is **not** an expired or rejected token — it means the request arrived with **no token at all**, on an endpoint that requires one. [AUTH V2](/api-reference/auth-v2) apps see this wherever V1 apps could use a bare `client_id`, because V2 limits anonymous access to public catalog data: movie, show and anime summaries by Simkl ID, episode lists, and the [Trending](/api-reference/trending) and [Calendar](/api-reference/calendar) data files. Everything else needs a token, **search included**.

**Refreshing will not help, because nothing was wrong with your token.** Check that the code path actually attaches the `Authorization` header — it is usually a browse or search screen written before sign-in was required. Search is the common one, since fetching a known title by ID nearby still works and makes the failure look inconsistent.

If you see this on an app you believe should allow anonymous access, the app is configured to require a user token; [get in touch](/support) rather than working around it.

### Forbidden

`403` — Refused. The request was understood but the caller is not permitted to perform it.

**Known `error` values**

| `error` | When |
| - | - |
| `insufficient_scope` | An [AUTH V2](/api-reference/auth-v2) token without `media:write` attempted a write |
| `oauth2_token_required` | An [AUTH V1](/api-reference/auth-v1) token was sent to an endpoint that requires [AUTH V2](/api-reference/auth-v2) — currently [Custom Lists](/guides/custom-lists) |
| `private_list` | The custom list exists but the caller is not its owner or a collaborator |
| `redirect_failed` | OAuth/PIN `redirect` parameter doesn't match a URL registered for your app |
| `forbidden` | Generic refusal — feature gated to Simkl PRO/VIP, or an action the user hasn't consented to |

**Fix**
Match the redirect URL exactly to what's registered in your [developer settings](https://simkl.com/settings/developer/). For PRO/VIP-gated features, surface an upgrade prompt rather than retrying.

<a id="insufficient-scope" />

#### `insufficient_scope` — the token cannot write

```json title="403 Forbidden" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "insufficient_scope",
  "code":    403,
  "message": "This action requires the \"media:read media:write\" scope"
}
```

The token is valid and the user is signed in — it was just minted without write access. The response also names the scope you needed, in an [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) header:

```http theme={"theme":{"light":"github-light","dark":"vesper"}}
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="media:read media:write"
```

**Refreshing will not fix this, and neither will asking for a wider scope on the refresh** — a grant's scope can never widen. The user has to authorize again with `media:read media:write` on the authorize URL.

Nearly always this traces back to one of the two traps on [Scopes](/api-reference/oauth2-scopes): the `scope` parameter was omitted (which grants read-only), or `media:write` was misspelled or miscased and silently ignored. Check what the token response's `scope` field actually said at mint time.

If the token really does hold `media:write`, or the failing call is a plain read, check whether your client attached a **request body to a `GET`** — [that counts as a write](/api-reference/oauth2-scopes#how-scope-is-enforced) regardless of the endpoint.

<a id="oauth2-token-required" />

#### `oauth2_token_required` — your token is the wrong generation

```json title="403 Forbidden" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "oauth2_token_required",
  "code":    403,
  "message": "Custom Lists require an OAuth 2.0 access token"
}
```

Nothing is wrong with the token — it is a valid V1 token being sent to an endpoint that only accepts V2. **Re-authorizing the same V1 app will not fix it**; the user has to authorize your [AUTH V2](/api-reference/auth-v2) registration, which is a different `client_id`. See [Migrating from V1 to V2](/guides/migrating-v1-to-v2).

Note it is a `403`, not a `401` — the request authenticated fine, it just is not permitted here. Do not send it down your token-refresh path.

```json title="403 Forbidden — redirect mismatch" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "redirect_failed",
  "code":    403,
  "message": "Invalid redirect_uri. This value must match a URL registered with the API Key."
}
```

### Not Found

`404` — The URL or resource doesn't exist.

<Tip>
  **Most catalog endpoints don't return 404 for missing IDs.** Hitting `/movies/99999999` returns `200 []` (empty array), not 404. Use `404` documentation in per-endpoint references as the source of truth — most read-only catalog endpoints simply return empty.
</Tip>

```json title="404 Not Found" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error": "id_err",
  "code":  404
}
```

### Conflict

`409` — The resource state conflicts with the request.

**Common causes**

* Tried to `POST /scrobble/stop` on a session that completed in the last hour.
* Tried to perform a write that would duplicate an existing record.

**Fix**
Treat `409` as soft-success when scrobbling — the user already finished the episode. Don't retry the same call. The body includes `watched_at` (when the original watch landed) and `expires_at` (when the 1-hour duplicate-window closes), so you can show the user the existing watch state instead of re-firing the call.

```json title="409 Conflict (POST /scrobble/stop)" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":      "already_watched",
  "watched_at": "2026-05-08T14:00:00Z",
  "expires_at": "2026-05-08T15:00:00Z"
}
```

<a id="client-id-failed" />

### client\_id failed

`412` — Your `client_id` is missing, wrong, or suspended — or a throttling block is still active.

**Common causes**

* Typo in `client_id` (use the value from [developer settings](https://simkl.com/settings/developer/), not a screenshot).
* App was suspended for a Terms of Service violation.
* Repeatedly exceeding the 1-POST-per-second cap, which places a temporary throttling block on the offending token or `client_id`. Further overages extend it.

**Fix**
Double-check the value. If it's correct, you are most likely throttled: stop, wait, and reduce your write rate before trying again. If it persists, [reach out on Discord](https://discord.gg/MJsWNE4).

<Note>
  **Running out of your daily quota does not produce this.** That returns `429` with `user_limit_exceeded` or `app_limit_exceeded`. A `412` means the request is wrong, or that you are already blocked — either way, retrying it unchanged will not help. See [Rate limits](/resources/rate-limits).
</Note>

```json title="412 client_id failed" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error":   "client_id_failed",
  "code":    412,
  "message": "Your client_id is wrong. Try another one"
}
```

### Too Many Requests

`429` — You sent too many requests. **Two different limits produce this status, and they need opposite responses**, so read the body before deciding how long to wait.

| Body | Which limit | Fix |
| - | - | - |
| `rate_limit` | The **per-second rate** — 10 GET/sec, 1 POST/sec. | Clears in about a second. Pause briefly and continue; exponential backoff is overkill. |
| `user_limit_exceeded` | That user's **daily** allowance ([AUTH V2](/api-reference/auth-v2)). | Read `Retry-After` and wait for the window. Only that user is affected. |
| `app_limit_exceeded` | Your app's **daily** allowance ([AUTH V1](/api-reference/auth-v1)). | Same, but every one of your users is affected at once. |

**Fix**
**Do not use exponential backoff on the daily ones.** The quota resets at midnight US Eastern, so retrying over seconds cannot clear it — read `Retry-After`, which carries the exact seconds remaining, or stop and tell the user. On `rate_limit`, the opposite: it clears almost immediately, so a long backoff just makes your app feel broken.

To avoid both: cache responses aggressively, use the [Trending](/api-reference/trending) and [Calendar](/api-reference/calendar) CDN files instead of polling, batch writes into arrays rather than sending them one at a time, and keep one request in flight at a time on uncached endpoints. Full guidance: [Rate limits](/resources/rate-limits).

<Warning>
  **`400` with `{"error": "RATE_LIMIT"}` is a different thing entirely**, despite the name. It is a short per-user write lock meaning your previous write for that user is still running. Serialise your writes for that user and retry in a moment — do not back off for minutes, and do not retry in parallel.
</Warning>

```json title="429 Too Many Requests" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error": "rate_limit",
  "code":  429
}
```

<a id="oauth2-errors" />

## OAuth 2.0 errors — `/oauth2/*`

These are the errors from the four [AUTH V2](/api-reference/auth-v2) endpoints themselves, not from API calls made with the resulting token. They use the [RFC 6749 envelope](#envelopes) described at the top — `error` plus an optional `error_description`, with no `code` field.

### `POST /oauth2/token`

| `error` | HTTP | When |
| - | - | - |
| `invalid_client` | `401` | Unknown or missing `client_id`, a V1 `client_id`, or a **Server apps & services** app whose `client_secret` is missing or wrong |
| `invalid_request` | `400` | A required parameter for the grant is absent — `code`, `code_verifier` or `device_code` |
| `invalid_grant` | `400` | The grant itself was rejected: code expired, already used, or issued to another client; [PKCE](/api-reference/oauth2-pkce) verification failed; `redirect_uri` does not match the authorization request; refresh token invalid, expired, or issued to another client |
| `invalid_scope` | `400` | A refresh asked for **wider** scope than the original grant |
| `unsupported_grant_type` | `400` | `grant_type` is not one of the three supported values. `client_credentials` lands here — there is no app-only grant |
| `authorization_pending` | `400` | Device flow: the user has not approved yet. **Keep polling** |
| `slow_down` | `400` | Device flow: you polled faster than `interval`. Add 5 seconds and wait |
| `expired_token` | `400` | Device flow: the `device_code` is invalid or past its 15 minutes. Start over |

<Warning>
  **The three device-flow values are not failures.** `authorization_pending` and `slow_down` are the normal shape of a polling loop, and only `expired_token` ends it. Treating any `400` from this endpoint as fatal is the most common way to break the [device flow](/api-reference/oauth2-device).
</Warning>

<Note>
  **`invalid_grant` is deliberately vague, and it is the one you will hit most.** A wrong verifier, a replayed code and an expired code all return the same error — the endpoint will not tell you which, because distinguishing them would help an attacker probe codes.

  Debug it by elimination: check the code is under 10 minutes old and used only once, then verify your [PKCE](/api-reference/oauth2-pkce) pair against the RFC test vector, then compare `redirect_uri` byte for byte with the one you sent to `/oauth2/authorize`.
</Note>

### `POST /oauth2/device` and `POST /oauth2/revoke`

| `error` | HTTP | When |
| - | - | - |
| `invalid_client` | `401` | Unknown or missing `client_id`, or a V1 `client_id`. `/oauth2/device` needs no secret; `/oauth2/revoke` does, for a **Server apps & services** app |

Otherwise **revoke always succeeds**. An unknown, already-revoked or foreign token returns `200` with an empty body, per [RFC 7009 §2.2](https://datatracker.ietf.org/doc/html/rfc7009#section-2.2) — never treat a `200` here as proof the token existed.

### `GET /oauth2/authorize`

The consent page is a browser destination, so its failures are not JSON for your code to parse. They split by whether Simkl can trust where it would send the user:

| Problem | What happens |
| - | - |
| `client_id` unknown, or `redirect_uri` not registered | An error page renders and **Simkl never redirects** — the open-redirect defence. Nothing reaches your callback |
| `code_challenge` missing, or a method other than `S256` | Redirects back with `error=invalid_request` and your `state` |
| The user declines | Redirects back with `error=access_denied` and your `state` |

Both redirect cases also carry `iss`. Validate `state` on the error path exactly as you would on success, and treat an unrecognised `error` value the same as `access_denied` rather than special-casing it.

## 5xx — our problem

### Internal Server Error

`500` — Something broke on our side.

**Fix**
Retry with exponential backoff. If the error persists for more than \~30 seconds, [report it on Discord](https://discord.gg/MJsWNE4).

```json title="500 Internal Server Error" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
  "error": "internal",
  "code":  500
}
```

### Bad Gateway

`502` — Simkl is being upgraded or briefly unreachable.

**Fix**
Retry with exponential backoff. Check [status](https://status.simkl.com/) for known issues.

### Service Unavailable

`503` — Servers are up but overloaded.

**Fix**
Retry after the delay. Respect any `Retry-After` header.

## Handling errors gracefully

### Exponential backoff

Recommended retry pattern for genuinely transient errors (`500`, `502`, `503`). A `429` needs its own handling — see [Too Many Requests](#too-many-requests) above, since only the per-second kind is worth retrying:

| Attempt | Wait before retry |
| - | - |
| 1 | 1 s |
| 2 | 2 s |
| 3 | 4 s |
| 4 | 8 s |
| 5 | 16 s (give up after this) |

Add jitter (random 0–1 s) so multiple clients don't synchronize.

```js title="retry-with-backoff.js" theme={"theme":{"light":"github-light","dark":"vesper"}}
async function retry(fn, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === attempts - 1) throw err;

      // A 429 carrying rate_limit is the per-second cap. It clears in about a
      // second, so wait out the window once rather than backing off.
      if (err.status === 429 && err.body?.error === 'rate_limit') {
        await new Promise((r) => setTimeout(r, 1000 + Math.random() * 250));
        continue;
      }

      // The daily 429s (user_limit_exceeded / app_limit_exceeded) are NOT
      // retried: the quota resets at midnight US Eastern, so backing off for
      // seconds cannot clear it. Honour Retry-After instead.
      if (![500, 502, 503].includes(err.status)) throw err;

      const delay = Math.min(1000 * 2 ** i, 60000) + Math.random() * 1000;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}
```

### Don't retry deterministic errors

`400`, `401`, `403`, `404`, `409`, `412` mean something is wrong with the request itself. Retrying without changing the request just wastes quota and triggers `429`.

### User-facing messaging

`message` is human-readable but written for developers. For end users:

| Code | Suggested user message |
| - | - |
| `401` | *Please sign in again to continue.* |
| `403` | *This action isn't available for your account.* |
| `404` | *We couldn't find that title.* |
| `429` | *Slow down — try again in a moment.* |
| `5xx` | *Simkl is having a moment. We'll retry automatically.* |

### Logging

Always log `error` and `code` together with your request URL. `error` is the stable contract you'll branch on; `code` confirms the HTTP class.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.