error (machine-readable identifier), code (integer — the HTTP status code echoed in the body), and an optional message (human-readable guidance).
Standard error envelope
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
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
Authorizationheader missing on a token-required endpoint.- The user’s
access_tokenwas revoked at Connected Apps settings. - A typo or truncation in the token value.
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
WWW-Authenticate header for clients that key off it:
AUTH V2 tokens report Note that the
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
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
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
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
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.
404 Not Found
Conflict
409 — The resource state conflicts with the request.
Common causes
- Tried to
POST /scrobble/stopon a session that completed in the last hour. - Tried to perform a write that would duplicate an existing record.
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.
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.
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
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 logerror and code together with your request URL. error is the stable contract you’ll branch on; code confirms the HTTP class.