Skip to main content
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).
Standard error envelope
The /oauth2/* endpoints use a different envelope. They follow RFC 6749 §5.2 instead: an error plus an optional error_description, and no code field — the HTTP status carries that on its own.
RFC 6749 envelope — /oauth2/token, /oauth2/device, /oauth2/revoke
This is what stock OAuth libraries expect, which is the point. It applies only to paths beginning /oauth2/ — an 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.
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.

At a glance

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.
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.

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.

4xx — your request

Bad Request

400 — The request was malformed. The message field usually identifies the offending parameter. Real error values returned Fix Read message, correct the request, then resend. Don’t retry without changing the request.
400 Bad Request

unauthorized_client — a V2 app on a V1 endpoint

400 Bad Request
Your client_id is registered for AUTH V2, and you sent it to an 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: 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.
If you are running both registrations side by side, 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.

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.
  • 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, 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, access tokens last 7 days, so a 401 is usually just expiry. Try the refresh grant first and only re-authorize if the refresh also fails. Sending a user through consent for a routine expiry is avoidable.
401 Unauthorized
The 401 response also carries an RFC 6750 §3 WWW-Authenticate header for clients that key off it:
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.
401 Unauthorized
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".

user_token_required — a different 401

401 Unauthorized
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 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 and 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 rather than working around it.

Forbidden

403 — Refused. The request was understood but the caller is not permitted to perform it. Known error values Fix Match the redirect URL exactly to what’s registered in your developer settings. For PRO/VIP-gated features, surface an upgrade prompt rather than retrying.

insufficient_scope — the token cannot write

403 Forbidden
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 header:
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: 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 regardless of the endpoint.

oauth2_token_required — your token is the wrong generation

403 Forbidden
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 registration, which is a different client_id. See Migrating from 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.
403 Forbidden — redirect mismatch

Not Found

404 — The URL or resource doesn’t exist.
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.
404 Not Found

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.
409 Conflict (POST /scrobble/stop)

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, 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.
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.
412 client_id failed

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. 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 and 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.
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.
429 Too Many Requests

OAuth 2.0 errors — /oauth2/*

These are the errors from the four AUTH V2 endpoints themselves, not from API calls made with the resulting token. They use the RFC 6749 envelope described at the top — error plus an optional error_description, with no code field.

POST /oauth2/token

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.
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 pair against the RFC test vector, then compare redirect_uri byte for byte with the one you sent to /oauth2/authorize.

POST /oauth2/device and POST /oauth2/revoke

Otherwise revoke always succeeds. An unknown, already-revoked or foreign token returns 200 with an empty body, per RFC 7009 §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: 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.
500 Internal Server Error

Bad Gateway

502 — Simkl is being upgraded or briefly unreachable. Fix Retry with exponential backoff. Check status 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 above, since only the per-second kind is worth retrying: Add jitter (random 0–1 s) so multiple clients don’t synchronize.
retry-with-backoff.js

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:

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.