# All endpoints
Source: https://api.simkl.org/all-endpoints
Every endpoint Simkl exposes, grouped by category. Use this page as a skim/grep index — each row links to the full reference.
48 endpoints across 14 categories. Click any row to jump to its full reference page (parameters, request/response shapes, Try-It playground). Sections and rows follow the same order as the API Reference sidebar.
**Legend.** **Bearer** = requires `Authorization: Bearer ` on top of `client_id`. **CDN** = served from `data.simkl.in`; no auth.
## Trending
[**Trending overview →**](/api-reference/trending) · Pre-built JSON, no auth. Mirrors Simkl's [Most Watched](https://simkl.com/movies/best-movies/most-watched/) pages.
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------------------ | ---------------------------------------------------------------- |
| `GET` | [`/discover/trending/{file}.json`](/api-reference/trending) | **CDN.** Combined trending: movies + TV + anime in one response. |
| `GET` | [`/discover/trending/{type}/{file}.json`](/api-reference/trending) | **CDN.** Per-category trending: movies, TV, or anime separately. |
| `GET` | [`/discover/dvd/{file}.json`](/api-reference/trending) | **CDN.** Latest popular DVD / Blu-ray releases (movies). |
## Calendar
[**Calendar overview →**](/api-reference/calendar) · CDN-hosted upcoming-episode and movie-release schedules.
| Method | Endpoint | Description |
| ------ | ----------------------------------------------------------------- | ---------------------------------------------------------------- |
| `GET` | [`/calendar/{type}.json`](/api-reference/calendar) | **CDN.** Rolling 33-day calendar — TV, anime, or movie releases. |
| `GET` | [`/calendar/{year}/{month}/{type}.json`](/api-reference/calendar) | **CDN.** Monthly archive — same shape, specific month. |
## Redirect
[**Redirect overview →**](/api-reference/redirect)
| Method | Endpoint | Description |
| ------ | -------------------------------------------- | ---------------------------------------------------------------------------- |
| `GET` | [`/redirect`](/api-reference/simkl/redirect) | Resolve a Simkl-canonical URL from a TMDB / IMDB / trailer / Twitter handle. |
## Auth
[**Auth overview →**](/api-reference/auth) · Browser and PIN flows for getting an `access_token`. See the [Authentication](/authentication) guide for which flow fits your platform.
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `GET` | [`/oauth/authorize`](/api-reference/simkl/authorize) | Send the user to Simkl's consent screen. Browser flow (web / mobile / desktop). |
| `POST` | [`/oauth/token`](/api-reference/simkl/exchange-token) | Exchange the authorization `code` (or PKCE verifier) for an `access_token`. |
| `GET` | [`/oauth/pin`](/api-reference/simkl/get-pin) | Request a short PIN code for TVs / consoles / CLIs that can't open a browser. |
| `GET` | [`/oauth/pin/{user_code}`](/api-reference/simkl/check-pin) | Poll until the user enters the PIN at `simkl.com/pin`. |
## Scrobble
[**Scrobble overview →**](/api-reference/scrobble) · Real-time playback tracking. All four require **Bearer**. See the [Scrobble guide](/guides/scrobble) for the lifecycle.
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------------ | ------------------------------------------------------------------------ |
| `POST` | [`/scrobble/checkin`](/api-reference/simkl/scrobble-checkin) | **Bearer.** Fire-and-forget — Simkl auto-completes when runtime elapses. |
| `POST` | [`/scrobble/pause`](/api-reference/simkl/scrobble-pause) | **Bearer.** Save current progress as a resumable playback. |
| `POST` | [`/scrobble/start`](/api-reference/simkl/scrobble-start) | **Bearer.** Begin / resume a playback session; shows "Watching now". |
| `POST` | [`/scrobble/stop`](/api-reference/simkl/scrobble-stop) | **Bearer.** End a session — ≥80% marks watched, below saves as playback. |
## Playback
[**Playback overview →**](/api-reference/playback) · Cross-device resume points saved by `/scrobble/pause` and `/scrobble/stop`.
| Method | Endpoint | Description |
| -------- | --------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `GET` | [`/sync/playback/{type}`](/api-reference/simkl/get-playback-sessions) | **Bearer.** List saved paused playbacks for cross-device resume. |
| `DELETE` | [`/sync/playback/{id}`](/api-reference/simkl/delete-playback) | **Bearer.** Remove a saved playback. |
## Sync
[**Sync overview →**](/api-reference/sync) · Read and write watch history, watchlists, and ratings. All require **Bearer**. See the [Sync guide](/guides/sync) for the activities-driven refresh strategy.
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `GET` | [`/sync/activities`](/api-reference/simkl/get-activities) | **Bearer.** Last-modified timestamps per category — the "is anything new?" gate before re-syncing. |
| `POST` | [`/sync/add-to-list`](/api-reference/simkl/add-to-list) | **Bearer.** Move titles between watchlist statuses (watching, plantowatch, completed, hold, dropped). |
| `GET` | [`/sync/all-items/{type}/{status}`](/api-reference/simkl/get-all-items) | **Bearer.** Read a user's library, filtered by type and status. Pair with `date_from` for deltas. |
| `POST` | [`/sync/history`](/api-reference/simkl/add-to-history) | **Bearer.** Mark items watched. Top-level `movies` / `shows` / `episodes` arrays. |
| `POST` | [`/sync/history/remove`](/api-reference/simkl/remove-from-history) | **Bearer.** Un-mark items watched. |
| `POST` | [`/sync/ratings`](/api-reference/simkl/add-ratings) | **Bearer.** Rate items 1–10. |
| `POST` | [`/sync/ratings/remove`](/api-reference/simkl/remove-ratings) | **Bearer.** Clear user-set ratings. |
| `GET` | [`/sync/ratings/{type}/{rating}`](/api-reference/simkl/get-user-ratings) | **Bearer.** List items the user has rated, optionally filtered by rating value. |
| `POST` | [`/sync/watched`](/api-reference/simkl/get-watched) | **Bearer.** Bulk "have I watched these?" lookup, by IDs or per-episode. |
## Search
[**Search overview →**](/api-reference/search) · Find titles by file name, external ID, free-text query, or "surprise me". Public — `client_id` is the only requirement.
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | [`/search/file`](/api-reference/simkl/search-by-file) | Identify a **single** video file the user just opened (desktop scrobblers, player overlays). **Not for library scraping** — use the media server's own metadata for that. |
| `GET` | [`/search/id`](/api-reference/simkl/search-by-id) | Legacy external-ID lookup. **Use [`/redirect`](/api-reference/redirect) instead** — header-only, Cloudflare-cached, and the canonical resolver. |
| `POST` | [`/search/random`](/api-reference/simkl/search-random) | Random pick with filters (genre, year, country, service). Optional bearer skips already-watched titles. |
| `GET` | [`/search/{type}`](/api-reference/simkl/search-by-text) | Free-text search across movies, TV, or anime. Paginated. |
## TV
[**TV overview →**](/api-reference/tv)
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | [`/tv/airing`](/api-reference/simkl/get-tv-airing) | What's airing today / tomorrow / on a specific date. |
| `GET` | [`/tv/best/{filter}`](/api-reference/simkl/get-best-tv) | Top-rated / most-watched / most-voted TV shows. |
| `GET` | [`/tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | Full episode list for a show, including specials. |
| `GET` | [`/tv/genres/...`](/api-reference/simkl/get-tv-genres) | TV by genre × type × country × network × year × sort. Paginated. |
| `GET` | [`/tv/premieres/{param}`](/api-reference/simkl/get-tv-premieres) | New premieres (`new`) or upcoming ones (`soon`). |
| `GET` | [`/tv/{id}`](/api-reference/simkl/get-tv-show) | Single show record — overview, network, runtime, status, genres, episode count, ratings, trailers, external IDs. Full record is returned by default. |
## Anime
[**Anime overview →**](/api-reference/anime)
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `GET` | [`/anime/airing`](/api-reference/simkl/get-anime-airing) | Today / tomorrow / specific-date anime airings. |
| `GET` | [`/anime/best/{filter}`](/api-reference/simkl/get-best-anime) | Top-rated / most-watched / most-voted anime. |
| `GET` | [`/anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | Episode list with AniDB / TVDB cross-references. |
| `GET` | [`/anime/genres/...`](/api-reference/simkl/get-anime-genres) | Anime by genre × type × network × year × sort. Paginated. |
| `GET` | [`/anime/premieres/{param}`](/api-reference/simkl/get-anime-premieres) | Recent or upcoming anime premieres. |
| `GET` | [`/anime/{id}`](/api-reference/simkl/get-anime) | Single anime record. `extended=full_anime_seasons` adds mapped TVDB seasons. |
## Movies
[**Movies overview →**](/api-reference/movies)
| Method | Endpoint | Description |
| ------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | [`/movies/genres/...`](/api-reference/simkl/get-movies-genres) | Movies by genre × type × country × year × sort. Paginated. |
| `GET` | [`/movies/{id}`](/api-reference/simkl/get-movie) | Single movie record — overview, director, runtime, country, genres, ratings, release dates, budget, revenue, trailers, external IDs, similar movies. Full record is returned by default. |
## Ratings
[**Ratings overview →**](/api-reference/ratings) · **Per-title ratings live inside the [detail endpoints](/api-reference/simkl/get-movie) (`/movies/{id}` · `/tv/{id}` · `/anime/{id}`)** under their `ratings` field. Use [`GET /redirect`](/api-reference/simkl/redirect) first if you only have an external ID. The bulk-watchlist endpoint below covers the "ratings for everything in a user's library" case.
| Method | Endpoint | Description |
| ------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | [`/ratings/{type}`](/api-reference/simkl/get-watchlist-ratings) | **Bearer.** Simkl community rating + droprate for every item in the user's watchlist (`?user_watchlist=watching,plantowatch,...` selects which bucket). |
## Users
[**Users overview →**](/api-reference/users)
| Method | Endpoint | Description |
| ------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `GET` | [`/users/recently-watched-background/{user_id}`](/api-reference/simkl/get-recently-watched-image) | Auto-generated cover image (PNG) from a user's recently-watched titles. No auth. |
| `POST` | [`/users/settings`](/api-reference/simkl/get-user-settings) | **Bearer.** The authenticated user's profile + privacy settings. |
| `POST` | [`/users/{user_id}/stats`](/api-reference/simkl/get-user-stats) | **Bearer.** Aggregated watch-stats for a Simkl user. |
## Changes
| Method | Endpoint | Description |
| ------ | ---------------------------------------------- | ----------------------------------------------------------------------------- |
| `GET` | [`/changes`](/api-reference/simkl/get-changes) | Modification feed for the public catalog — titles updated since a given date. |
***
**Looking for something specific?** Hit Ctrl+K (or ⌘+K) to search across every endpoint, guide, and convention.
# About Anime
Source: https://api.simkl.org/api-reference/anime
Look up anime, browse what's airing or premiering, and pull episode lists.
The Anime API mirrors the TV API but uses **AniDB** as its primary source for episode/season numbering. None of these endpoints require an OAuth `token` — a `client_id` is enough.
In addition to the standard show fields, anime responses include `anime_type` (one of `tv`, `special`, `ova`, `movie`, `music video`, `ona`) and `en_title`.
Anime episodes are numbered using AniDB. If you receive scrobbles or IDs from TMDB/TVDB-based sources, expect the API to remap them — see the [Scrobble overview](/api-reference/scrobble) for details.
## Look up an anime
Item-level lookups for when you already know which anime you want. Both are Cloudflare-cached by Simkl ID — see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed).
`GET /anime/{id}` — full record (overview, studios, network, runtime, status, genres, episode count, AniDB-mapped TVDB seasons, related titles, ratings, posters, fanart, trailers, external IDs, alternate titles).
`GET /anime/episodes/{id}` — every episode with `aired` flags and airdates. Specials appear with `type: "special"`.
If you only have an external ID (MAL, AniDB, AniList, Kitsu, etc.), resolve it to a Simkl ID via [`GET /redirect`](/api-reference/redirect) first — header-only, Cloudflare-cached, and the canonical resolver.
On `GET /anime/{id}`, add `?extended=full_anime_seasons` to additionally receive `mapped_tvdb_seasons` plus per-season IMDB/TVDB/TMDB IDs for shows with 2+ seasons — useful when bridging anime-native episode numbering to TVDB-style players.
## Browse & discover
Find anime by what's on now, what's coming, what's top-rated, or by genre / network / year.
`GET /anime/airing` — what's airing right now.
`GET /anime/premieres/{param}` — upcoming season and series premieres.
`GET /anime/best/{filter}` — top-rated anime by filter (year, all-time, etc.).
`GET /anime/genres/...` — browse by genre, network, year.
## Pre-built data files (no per-user cost)
Two static JSON resources on the CDN cover the most common "what's hot / what's airing" surfaces without paying any per-user request budget. Send the standard URL parameters and User-Agent; no `Authorization` token needed.
`https://data.simkl.in/trending/anime_today.json` + week / month variants, plus Top-100 and Top-500 versions. Refreshed daily. Drives "Most Watched" / "Trending Now" surfaces without polling.
`https://data.simkl.in/calendar/anime.json` + per-month archives at `/calendar/{YEAR}/{MONTH}/anime.json`. Updated every 6 hours. Far cheaper than polling `/anime/airing`.
# Choose a flow
Source: https://api.simkl.org/api-reference/auth
OAuth or PIN — pick the one that matches your platform and ship in under an hour.
Every API call that touches user data needs a user `access_token`. Simkl gives you three ways to get one — they're all variants of OAuth 2.0 from the user's perspective, but the integration story is very different. Pick the one that matches the device your app runs on; once you have a token, the rest of the API is identical.
For **server-side web apps** that can keep a `client_secret`. The user logs in via their browser; your backend exchanges the `code` for a token.
For **mobile, SPA, browser extensions, desktop binaries** — any client where you can't safely embed `client_secret`. Same browser-based UX, no secret required.
For **TVs, consoles, watches, CLIs, and media-server plugins**. Show a 5-character code; the user enters it on their phone.
## Find your platform
| Platform | Use | Recommended UI |
| --------------------- | --------- | ----------------------------------------------------------- |
| **iOS / iPadOS** | OAuth 2.0 | `ASWebAuthenticationSession` (iOS 12+) |
| **Android** | OAuth 2.0 | Chrome **Custom Tabs** (`androidx.browser`) |
| **React Native** | OAuth 2.0 | `expo-web-browser` or `react-native-app-auth` |
| **Flutter** | OAuth 2.0 | `flutter_web_auth_2` |
| **Capacitor / Ionic** | OAuth 2.0 | `@capacitor/browser` |
| **watchOS / Wear OS** | **PIN** | The watch shows the code; the user enters it on their phone |
**Don't use an embedded `WebView` for OAuth on mobile.** Identity providers used by Simkl's login page — **Google sign-in**, **email auth**, and others — refuse to render inside embedded WebViews (Android `WebView`, iOS `WKWebView`). Users hit a blank screen or a "this browser is not supported" error and can't log in.
Use the platform's secure web-auth session instead — it runs in the user's real browser context, shares their existing login cookies, and is accepted by every provider:
* **iOS / iPadOS** → [`ASWebAuthenticationSession`](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession). Mandatory — the App Store rejects the older `SFAuthenticationSession`. On iOS 17.4+, prefer the newer initializer that handles universal links cleanly.
* **Android** → [Chrome **Custom Tabs**](https://developer.chrome.com/docs/android/custom-tabs) (`androidx.browser:browser`). For Chrome-only apps you can opt into the newer purpose-built [**Auth Tab**](https://developer.chrome.com/docs/android/custom-tabs/guide-auth-tab) — Custom Tabs remains the broadest-compatibility default.
| Platform | Use | How |
| --------------------------------------------------- | --------- | -------------------------------------------------------------------------- |
| **SPA (React, Vue, Svelte)** | OAuth 2.0 | Redirect → backend exchanges `code` → backend sets httpOnly cookie |
| **Next.js / Remix / SvelteKit** | OAuth 2.0 | Server route handles the redirect and token exchange |
| **Server-rendered (Rails, Django, Laravel, Rails)** | OAuth 2.0 | Standard server-side OAuth |
| **Browser extension** | OAuth 2.0 | `chrome.identity.launchWebAuthFlow` / `browser.identity.launchWebAuthFlow` |
| Platform | Use | How |
| -------------------------------------- | --------- | --------------------------------------------------------------------------------- |
| **macOS / Windows / Linux** native | OAuth 2.0 | Open the **system browser**; receive the redirect via a localhost loopback server |
| **Electron / Tauri** | OAuth 2.0 | System browser + loopback. Don't embed the auth page in your app window |
| **Cross-platform GUI (Qt, GTK, .NET)** | OAuth 2.0 | Same — system browser + loopback |
| Platform | Use |
| ----------------------------- | --- |
| **Apple TV (tvOS)** | PIN |
| **Android TV / Google TV** | PIN |
| **Fire TV** | PIN |
| **Roku** | PIN |
| **Samsung Tizen / LG webOS** | PIN |
| **PlayStation, Xbox, Switch** | PIN |
| **Steam Deck (gamepad mode)** | PIN |
Typing on a remote or controller is painful. PIN flow lets the user authorize from their phone in seconds.
| Platform | Use |
| ---------------------------------------- | --- |
| **CLI tool, system service, daemon** | PIN |
| **Plex / Jellyfin / Emby / Kodi plugin** | PIN |
| **Discord / Slack bot, Telegram bot** | PIN |
| **Hardware/IoT, NAS app** | PIN |
## At-a-glance comparison
| | OAuth 2.0 | Public PKCE | PIN |
| ------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| **User experience** | Tap login → browser → approve → back to app | Same as OAuth 2.0 | App shows code → user types it at simkl.com/pin → app continues |
| **HTTP calls** | 1 redirect + 1 token POST | 1 redirect + 1 token POST | 1 code request + N polls |
| **Time to token** | \~5 seconds | \~5 seconds | 30 seconds – 2 minutes |
| **Needs `client_secret`** | Yes | **No** — uses `code_verifier` + `code_challenge` | No |
| **Needs `redirect_uri`** | Yes (pre-registered, byte-for-byte) | Yes, OR omit entirely if no redirect URI is registered (consent completes on simkl.com) | No |
| **Code TTL** | Short-lived — exchange immediately | Short-lived — exchange immediately | 15 minutes (`expires_in: 900`) |
| **Best for** | Server-side web apps | Mobile, SPA, browser extensions, desktop binaries | TVs, consoles, watches, CLIs, plugins |
## How the OAuth flow works
**Two domains, two roles.** OAuth uses **two different hosts** — easy to mix up, and the most common cause of "404 Not Found" during integration:
| Endpoint | Host | What it does |
| ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------- |
| `GET /oauth/authorize` | **`simkl.com`** | Browser-facing consent page. The user lands here, signs in, and approves your app. |
| `POST /oauth/token` | **`api.simkl.com`** | Server-to-server code exchange. Your backend posts the `code` here and gets back an `access_token`. |
If your authorize URL points at `api.simkl.com` you'll get a 404 — it has to be `simkl.com`.
Open `https://simkl.com/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&app-name=my-app-name&app-version=1.0` in the user's **system browser** (or a Custom Tab / `ASWebAuthenticationSession` on mobile).
**Note the host.** This is `https://simkl.com/...`, not `https://api.simkl.com/...`. Only the *token exchange* in step 3 hits the API host.
Public clients should also send a [PKCE](/api-reference/oauth-pkce) `code_challenge` (and optional `code_challenge_method`, default `S256`) — this lets you skip `client_secret`.
Simkl shows a consent screen. After approval, Simkl redirects to your `redirect_uri` with `?code=AUTHORIZATION_CODE` appended (and `&state=…` if you sent one).
**User denial doesn't return an `error=` parameter.** If the user clicks "No" on the consent screen, Simkl redirects to `/` on simkl.com — **not** back to your `redirect_uri` with `error=access_denied`. Your client should treat any flow where the redirect never lands as a denial / timeout: if your callback handler hasn't received a `code` within a sensible window (e.g. 5 minutes), surface a *"sign-in cancelled"* message and let the user retry.
`POST /oauth/token` with the `code`, your `client_id`, and either:
* **`client_secret` + `redirect_uri`** (confidential clients), or
* **`code_verifier`** (PKCE — public clients, no secret required).
The response contains your `access_token`.
The `code` is short-lived. Exchange it immediately — don't queue it or pass it through a slow pipeline.
**Both content-types and both credential locations work.** Simkl's `POST /oauth/token` accepts:
* `Content-Type: application/x-www-form-urlencoded` (the RFC 6749 §3.2 default) **or** `Content-Type: application/json` — pick whichever your HTTP client prefers
* Client credentials in the request body (`client_id` + `client_secret` parameters) **or** in the `Authorization: Basic` header (RFC 6749 §2.3.1) — both paths are honored
That means **off-the-shelf OAuth libraries work out-of-the-box** with no custom encoding or auth-method config. The two equivalent ways to call the token endpoint:
```bash form-encoded (RFC 6749 §3.2 default) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
```bash JSON body theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "YOUR_REDIRECT_URI",
"grant_type": "authorization_code"
}'
```
For library-specific examples (Python, Node, Java, Go, PHP), see [OAuth client libraries](/api-reference/oauth-libraries) — most are zero-config.
Save the `access_token` securely. It's long-lived and only stops working when the user revokes your app from [Connected Apps settings](https://simkl.com/settings/connected-apps/). Send it as `Authorization: Bearer …` on every authenticated request.
### OAuth code samples
```bash curl theme={"theme":{"light":"github-light","dark":"vesper"}}
# Step 1 — send the user here in their browser:
# https://simkl.com/oauth/authorize?response_type=code
# &client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI
# &app-name=my-app-name&app-version=1.0
# Step 2 — your redirect_uri receives ?code=AUTH_CODE.
# Exchange it immediately:
curl -X POST "https://api.simkl.com/oauth/token?client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H 'Content-Type: application/json' \
-H 'User-Agent: my-app-name/1.0' \
-d "{
\"code\": \"$AUTH_CODE\",
\"client_id\": \"$CLIENT_ID\",
\"client_secret\": \"$CLIENT_SECRET\",
\"redirect_uri\": \"$REDIRECT_URI\",
\"grant_type\": \"authorization_code\"
}"
# → { "access_token": "...", "token_type": "bearer", "scope": "public", "expires_in": 157680000 }
```
```swift iOS (Swift) theme={"theme":{"light":"github-light","dark":"vesper"}}
import AuthenticationServices
let authURL = URL(string:
"https://simkl.com/oauth/authorize?response_type=code" +
"&client_id=\(clientId)&redirect_uri=\(redirectURI)" +
"&app-name=my-app-name&app-version=1.0")!
let session = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: "yourapp" // matches the scheme in redirect_uri
) { callbackURL, error in
guard let url = callbackURL,
let code = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "code" })?.value
else { return }
// POST code to https://api.simkl.com/oauth/token,
// then store access_token in Keychain.
}
session.presentationContextProvider = self
session.start()
```
```kotlin Android (Kotlin) theme={"theme":{"light":"github-light","dark":"vesper"}}
// build.gradle: implementation "androidx.browser:browser:1.7.0"
import androidx.browser.customtabs.CustomTabsIntent
val authUrl = Uri.parse("https://simkl.com/oauth/authorize")
.buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", BuildConfig.SIMKL_CLIENT_ID)
.appendQueryParameter("redirect_uri", "yourapp://oauth")
.appendQueryParameter("app-name", "my-app-name")
.appendQueryParameter("app-version", "1.0")
.build()
CustomTabsIntent.Builder().build().launchUrl(this, authUrl)
// Handle the redirect via an intent-filter on yourapp://oauth,
// then POST the ?code= to /oauth/token from your backend.
```
```js Node / TypeScript theme={"theme":{"light":"github-light","dark":"vesper"}}
// In your /oauth/callback handler, after extracting `code` from the query:
const params = new URLSearchParams({
client_id: process.env.SIMKL_CLIENT_ID,
'app-name': 'my-app-name',
'app-version': '1.0',
});
const r = await fetch(`https://api.simkl.com/oauth/token?${params}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'my-app-name/1.0',
},
body: JSON.stringify({
code,
client_id: process.env.SIMKL_CLIENT_ID,
client_secret: process.env.SIMKL_CLIENT_SECRET,
redirect_uri: process.env.SIMKL_REDIRECT_URI,
grant_type: 'authorization_code',
}),
});
const { access_token } = await r.json();
// Set as httpOnly cookie or store server-side.
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests
r = requests.post(
'https://api.simkl.com/oauth/token',
params={
'client_id': CLIENT_ID,
'app-name': 'my-app-name',
'app-version': '1.0',
},
headers={'User-Agent': 'my-app-name/1.0'},
json={
'code': auth_code,
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET,
'redirect_uri': REDIRECT_URI,
'grant_type': 'authorization_code',
},
)
access_token = r.json()['access_token']
```
### PKCE for public clients
If your app can't safely keep a `client_secret` — mobile, SPA, browser extension, desktop binary — use **PKCE** instead of the confidential flow above. The user experience is identical (browser-based OAuth); the difference is replacing the secret with a one-time `code_verifier` + `code_challenge` pair the client generates locally.
Step-by-step PKCE flow ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)), per-platform recipes (iOS / Android / Web SPA / Desktop Python), common pitfalls (verifier mismatch, code expiry, redirect URI byte-for-byte rules, multi-flow verifier storage), and the "no registered redirect URI" mode Simkl supports for PKCE.
## How the PIN flow works
`GET /oauth/pin?client_id=…`. The response contains:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"result": "OK",
"device_code": "DEVICE_CODE",
"user_code": "ABCDE",
"verification_uri": "https://simkl.com/pin",
"verification_url": "https://simkl.com/pin",
"expires_in": 900,
"interval": 5
}
```
`user_code` is the 5-character code you show to the user. `expires_in` is 900 seconds (15 min). `interval` is 5 seconds — your polling cadence.
The `device_code` field is returned as the literal string `"DEVICE_CODE"` (not a per-request device code value). It's a placeholder for compatibility with the OAuth 2.0 Device Authorization Grant response shape. Clients only need to remember `user_code` — that's what you poll on and what the user enters.
The response also includes a `verification_url` key with the same value, kept as an alias. Read `verification_uri` — that's the RFC 8628 §3.2 spelling.
Display `user_code` prominently. Tell the user: "Go to `simkl.com/pin` on your phone and enter `ABCDE`."
`GET /oauth/pin/{USER_CODE}?client_id=…` every `interval` seconds. There are two response shapes:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
// Still pending — keep polling
{ "result": "KO", "message": "Authorization pending" }
// User approved — stop polling, store the token
{ "result": "OK", "access_token": "..." }
```
Respect the returned `interval` (5 seconds). Polling faster won't make the user enter their PIN faster. Once `expires_in` (900 seconds) elapses, the `user_code` is dead — request a fresh one and restart.
**Stop polling as soon as you receive the `access_token`.** After a successful authorization the server deletes the code, and any subsequent poll on the deleted (or any unknown) `user_code` falls through to the *create-a-new-code* branch — you'll get back the same shape as `GET /oauth/pin` with a brand-new `user_code`. Detect any response containing `device_code` as "the original code is gone" and stop.
Once you receive `access_token`, stop polling and store it. From here on, the device works exactly like an OAuth client.
### PIN code samples
```bash curl theme={"theme":{"light":"github-light","dark":"vesper"}}
PARAMS="client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0"
UA="my-app-name/1.0"
# Step 1 — request a code:
curl "https://api.simkl.com/oauth/pin?$PARAMS" -H "User-Agent: $UA"
# → { "user_code": "ABCDE", "verification_uri": "https://simkl.com/pin/",
# "verification_url": "https://simkl.com/pin/",
# "expires_in": 900, "interval": 5 }
# Step 2 — show ABCDE to the user.
# Step 3 — poll every 5 seconds:
while true; do
RESP=$(curl -s "https://api.simkl.com/oauth/pin/ABCDE?$PARAMS" -H "User-Agent: $UA")
echo "$RESP" | grep -q access_token && { echo "$RESP"; break; }
sleep 5
done
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests, time
PARAMS = {
'client_id': CLIENT_ID,
'app-name': 'my-app-name',
'app-version': '1.0',
}
HEADERS = {'User-Agent': 'my-app-name/1.0'}
# 1. Request a code
pin = requests.get(
'https://api.simkl.com/oauth/pin',
params=PARAMS,
headers=HEADERS,
).json()
print(f"Visit {pin['verification_uri']} and enter: {pin['user_code']}")
# 2. Poll
deadline = time.time() + pin['expires_in']
while time.time() < deadline:
r = requests.get(
f"https://api.simkl.com/oauth/pin/{pin['user_code']}",
params=PARAMS,
headers=HEADERS,
).json()
if r.get('result') == 'OK' and 'access_token' in r:
access_token = r['access_token']
break
time.sleep(pin['interval'])
```
```js Node / TypeScript theme={"theme":{"light":"github-light","dark":"vesper"}}
const PARAMS = `client_id=${CLIENT_ID}&app-name=my-app-name&app-version=1.0`;
const HEADERS = { 'User-Agent': 'my-app-name/1.0' };
// 1. Request a code
const pin = await fetch(
`https://api.simkl.com/oauth/pin?${PARAMS}`,
{ headers: HEADERS }
).then(r => r.json());
console.log(`Visit ${pin.verification_uri} and enter: ${pin.user_code}`);
// 2. Poll
const deadline = Date.now() + pin.expires_in * 1000;
while (Date.now() < deadline) {
const r = await fetch(
`https://api.simkl.com/oauth/pin/${pin.user_code}?${PARAMS}`,
{ headers: HEADERS }
).then(r => r.json());
if (r.access_token) { /* done */ break; }
await new Promise(res => setTimeout(res, pin.interval * 1000));
}
```
## After you have a token
Public endpoints (Search, Movies, TV, Anime, Ratings, Redirect) only need the [required URL parameters](/conventions/headers#required-url-parameters). Endpoints that touch user data also need `Authorization`.
| Where | Value |
| ---------------------- | --------------------------------------------------------------- |
| URL params | `client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0` |
| `Authorization` header | `Bearer YOUR_ACCESS_TOKEN` (when token-required) |
| `User-Agent` header | `my-app-name/1.0` |
| `Content-Type` header | `application/json` (for POST) |
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/activities?client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
## Token lifecycle
The token-mint response carries `expires_in: 157680000` — **5 years in seconds**. In practice tokens remain valid until the user revokes your app, so the lifetime advertised is more of a sentinel than a refresh hint (there's no refresh-token grant — once `expires_in` does run out, the user has to re-consent through `/oauth/authorize`). Store one and reuse it until the user revokes — see "What happens when a user revokes access?" below.
The user can revoke from [Connected Apps settings](https://simkl.com/settings/connected-apps/). After revocation, every authenticated call returns **`401 Unauthorized`**. Detect this and prompt the user to re-authorize.
| Platform | Storage |
| --------------------------- | ---------------------------------------------------------------- |
| **iOS** | Keychain |
| **Android** | EncryptedSharedPreferences or the Android Keystore |
| **macOS / Windows / Linux** | OS keychain (Keychain Access, Credential Manager, libsecret) |
| **Web** | `httpOnly` cookie set by your backend — **never** `localStorage` |
| **CLI / server** | A file with restrictive permissions, or a secrets manager |
All tokens currently return `scope: "public"`. There's no granular permission system — every token grants every permission your app has been approved for.
Simkl returns the **same `access_token`** both times for a given `(app, user)` pair — whether the user re-runs the standard flow, PKCE, or the PIN flow. The server tracks how often the token has been issued (an internal usage counter) but doesn't rotate the token itself. Storing the latest response is safe; you don't need to invalidate older ones because there aren't multiple ones. The token only stops working when the user revokes your app at [Connected Apps settings](https://simkl.com/settings/connected-apps/).
## Common pitfalls
**Don't use an embedded `WebView` for OAuth on mobile.** Federated providers used by Simkl's login page — **Google sign-in**, **email auth**, and others — refuse to render inside embedded WebViews. Users see a blank screen or a "browser not supported" error and can't sign in. Use [`ASWebAuthenticationSession`](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) on iOS or [Chrome **Custom Tabs**](https://developer.chrome.com/docs/android/custom-tabs) on Android — both run in the user's real browser context and work with every provider.
**Don't ship `client_secret` in a public binary.** Anything compiled into the user's app — mobile, desktop, browser extension, SPA — should be considered leaked. Use [**Public PKCE**](/api-reference/oauth-pkce) (`code_verifier` + `code_challenge`) instead, or use the PIN flow. Both work without a secret.
**`redirect_uri` must match byte-for-byte.** Trailing slash, scheme (`http` vs `https`), port, casing — all of it. Mismatches return an error before the user even sees the consent screen.
**The OAuth `code` is single-use.** Once you POST it to `/oauth/token`, the server deletes it — even if your exchange request fails (network error, validation mismatch, etc.) the code is consumed and won't work a second time. Don't log it. Don't queue it. Don't retry a failed exchange with the same code — restart the flow from `/oauth/authorize` instead.
**Reuse the same token until it stops working.** Don't re-run the auth flow on every app launch — that's a guaranteed way to annoy users. Save it once, check on launch that it still works (any quick authenticated GET), and only re-prompt on `401` (the user revoked your app).
## Pick a flow
Confidential clients (server-side web). `client_id` + `client_secret` + `redirect_uri`. Parameter reference and example responses.
Public clients (mobile, SPA, extensions, desktop). `client_id` + `code_verifier` / `code_challenge`. Per-platform recipes.
Browser-less devices (TVs, consoles, watches, CLIs). Show a code, poll until the user enters it.
# Calendar data files
Source: https://api.simkl.org/api-reference/calendar
CDN-hosted JSON for upcoming episodes, premieres, and movie releases.
**No auth required.** Calendar data is public — send the standard [required URL parameters](/conventions/headers#required-url-parameters) (`client_id`, `app-name`, `app-version`) and a `User-Agent` header, but no user `Authorization` token.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
Simkl publishes pre-built JSON calendars on its CDN. Use them to power the **"Upcoming"**, **"Next"**, **"Schedule"**, or **"Calendar"** sections of your app — without per-user API calls.
The files are regenerated **every 6 hours** and cached on a CDN for **5 hours**. Check the response's `Last-Modified` header to know when they were last refreshed.
The CDN ignores all query strings. Don't append `?random=...` or similar — you'll just bust the cache for everyone with no benefit. The same URL serves identical content.
## Why use these instead of API calls
* No user `Authorization` token required (still send `client_id`, `app-name`, `app-version`, and `User-Agent` like every Simkl request).
* Hugely cheaper — one file covers thousands of titles.
* Cacheable on the user's device for hours.
A typical pattern: when the user opens their watchlist, **combine** the calendar JSON (cached locally for 3–6 hours) with the user's [synced watchlist](/guides/sync) (also cached) to compute "Next episode airs in 3 days" — without re-syncing the user's watchlist every time.
## Files
### Airing next (rolling window — yesterday + next 33 days)
| Catalog | URL |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| TV | [https://data.simkl.in/calendar/tv.json?client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://data.simkl.in/calendar/tv.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Anime | [https://data.simkl.in/calendar/anime.json?client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://data.simkl.in/calendar/anime.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Movie releases | [https://data.simkl.in/calendar/movie\_release.json?client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://data.simkl.in/calendar/movie_release.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
### Monthly archives
The current month is regenerated **every 6 hours**; previous months are regenerated **every 24 hours** for the last 12 months. URL pattern:
```
https://data.simkl.in/calendar/{YEAR}/{MONTH}/{tv|anime|movie_release}.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
```
Example for **May 2026**:
```
https://data.simkl.in/calendar/2026/5/tv.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
https://data.simkl.in/calendar/2026/5/anime.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
https://data.simkl.in/calendar/2026/5/movie_release.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
```
## What's in each item
Every calendar entry carries everything you need to render the schedule without a second API call per item — display title + slug, [poster image path](/conventions/images), Simkl rank + ratings, an airing timestamp with a timezone offset, plus IDs across Simkl, TMDB, IMDB, TVDB, MAL, AniDB, AniList, and Kitsu where available.
```json Sample item (TV) theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "Ruin Road",
"poster": "94/941b16f3a4d2f5e0c8",
"date": "2026-05-16T00:00:00-05:00",
"release_date": "2016-10-15",
"rank": 0,
"url": "https://simkl.com/tv/1520136/ruin-road",
"ratings": {
"simkl": { "rating": 7.4, "votes": 132 },
"imdb": { "rating": 7.6, "votes": 4218 }
},
"ids": {
"simkl_id": 1520136,
"slug": "ruin-road",
"imdb": "tt12345678",
"tmdb": "204821",
"tvdb": "418273"
},
"episode": {
"season": 2,
"episode": 7,
"url": "https://simkl.com/tv/1520136/ruin-road/season-2/episode-7"
}
}
```
### Field reference
Display title.
Image path fragment. Combine with the prefixes in [Image conventions](/conventions/images) — for example `https://simkl.in/posters/{poster}_m.webp`. `null` when no poster is on file (Type 4 null — see [Null and missing values](/conventions/null-values#type-4)); fall back to `https://simkl.in/poster_no_pic.png` (see [fallbacks](/conventions/images#fallback-when-images-are-missing)).
Air / release timestamp **with the catalog's timezone offset** (e.g. `2026-05-16T00:00:00-05:00` for US TV, `+09:00` for Japanese anime). Use this for chronological sorting and timezone-aware display.
Original premiere date — the show or movie's first-ever release, not the per-episode air date (that's in `date`).
Simkl popularity rank. `0` for items that aren't yet ranked (common on new / regional titles). Lower non-zero values = more popular.
Absolute `simkl.com` URL with slug (e.g. `https://simkl.com/tv/1520136/ruin-road`).
Aggregate ratings keyed by source. Only sources with on-file data appear.
Simkl community rating.IMDb rating (TV / movies).MyAnimeList rating (anime).
External and Simkl IDs. Always carries `simkl_id` + `slug`. `tmdb` is near-universal; `imdb` appears on TV / movies, `mal` / `anidb` / `anilist` / `kitsu` on anime. Additional slug variants (`letterslug`, `traktmslug`, `tvdbslug`, `mdlslug`, …) may appear on items with those platform links — the response is permissive.
Canonical Simkl ID — primary key for `/movies/{id}`, `/tv/{id}`, `/anime/{id}`.URL-safe slug.IMDb ID, e.g. `tt12345678`.TMDB ID.TVDB ID (TV / anime).MyAnimeList ID (anime).AniDB ID (anime).AniList ID (anime).Kitsu ID (anime).
**TV / anime only — omitted on movie files.** Episode reference for the airing.
Season number. **Present on TV; omitted on anime** (anime uses AniDB sequential numbering — no seasons). Type 2 null (key absence) on anime — see [Null and missing values](/conventions/null-values#type-2).Episode number within the season (1-based).Absolute `simkl.com` URL for the episode.
**Anime files only — omitted on TV and movie files.** Catalog format. One of `tv`, `movie`, `ova`, `ona`, `special`, `music`.
# Introduction
Source: https://api.simkl.org/api-reference/introduction
Everything you need to know before you make your first call to the Simkl API.
The **Simkl API** is a JSON-over-HTTPS REST API for Movies, TV Shows, and Anime. Every endpoint listed in this reference returns JSON, accepts JSON for `POST` bodies, and follows the conventions described below.
If you've never made a Simkl API call before, start with the [Quickstart](/quickstart) — you'll have an `access_token` and a working request in under five minutes.
## Base URL
All endpoints live under a single host:
```
https://api.simkl.com
```
Pre-built JSON files (Trending, Calendar) are served from a separate CDN host:
```
https://data.simkl.in
```
## Required URL parameters
Append these to **every** request URL — both public catalog calls and authenticated user calls:
```
/endpoint?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
```
| Parameter | Required | Value |
| ------------- | -------- | --------------------------------------------------------------------------------------- |
| `client_id` | always | Your `client_id` from [your developer settings](https://simkl.com/settings/developer/). |
| `app-name` | always | Short, lowercase identifier for your app (e.g. `plex-scrobbler`, `kodi-trakt-bridge`). |
| `app-version` | always | The current version of your app, e.g. `1.0`, `2.4.1`. |
These three parameters help us see which apps are using the API, debug issues you report, and route around outages. They're cheap to send — please always include them.
See [Headers and required parameters](/conventions/headers) for the full reference, including the `User-Agent` and `Authorization` headers.
## Authentication
Endpoints that read or write user data require an `Authorization: Bearer ACCESS_TOKEN` header. You obtain a token via one of two flows:
For iOS, Android, web, and desktop apps that can open a browser.
For TVs, consoles, watches, CLIs, and media-server plugins.
Endpoints that don't need a user token are marked **No auth** in the API Reference.
## Conventions you'll see throughout
`page` and `limit` URL parameters; `X-Pagination-*` response headers.
What signals Simkl returns when traffic is throttled and how to back off.
Every HTTP status code, with a stable anchor for each.
The Movie / Show / Anime / Episode shapes every endpoint speaks.
The `extended` URL parameter for richer fields.
How to compose poster and fanart URLs from the path fragments returned in responses.
## Try it in the playground
Every endpoint in the API Reference has an interactive playground. Paste your `client_id`, `app-name`, `app-version`, and `access_token` once at the top of any reference page and they're reused across the rest.
Need a `client_id`? [Create an app](https://simkl.com/settings/developer/) — free, no approval required.
## Tooling & codegen
This entire API reference is generated from a hand-curated **OpenAPI 3.1** specification. The same file is served at a stable URL so you can plug it into your own tooling — Postman / Insomnia for ad-hoc requests, an OpenAPI codegen tool (openapi-generator, oapi-codegen, kiota, etc.) for a typed client in your language, or an LLM tool that ingests OpenAPI for tool-use mapping.
`https://api.simkl.org/openapi.json` — the full machine-readable spec for every endpoint on this site. Import into Postman, Insomnia, or your codegen of choice.
The whole doc site flattened into a single text file for ingestion by Claude / ChatGPT / Perplexity-style agents. See also the shorter [`llms.txt`](/llms.txt) index.
# About Movies
Source: https://api.simkl.org/api-reference/movies
Browse and look up movies — discover by genre/country/year, or pull full details for a single title.
The Movies API returns metadata about theatrical movies in Simkl's catalog. None of these endpoints require an OAuth `token` — a `client_id` is enough.
## Look up a movie
`GET /movies/{id}` — full record (overview, director, runtime, country, certification, genres, ratings, similar-movie recommendations, posters, fanart, trailers, external IDs, alternate titles, per-region release dates, budget, revenue). Cloudflare-cached by Simkl ID — see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed).
If you only have an external ID (IMDb, TMDB), resolve it to a Simkl ID via [`GET /redirect`](/api-reference/redirect) first — header-only, Cloudflare-cached, and the canonical resolver.
## Browse & discover
`GET /movies/genres/...` — browse by genre, country, year, and sort order.
## Pre-built data files (no per-user cost)
Two static JSON resources on the CDN cover the most common "what's hot / what's releasing" surfaces without paying any per-user request budget. Send the standard URL parameters and User-Agent; no `Authorization` token needed.
`https://data.simkl.in/trending/movies_today.json` + week / month variants, plus Top-100 and Top-500 versions. Refreshed daily. Drives "Most Watched" / "Trending Now" surfaces without polling.
`https://data.simkl.in/calendar/movie_release.json` + per-month archives at `/calendar/{YEAR}/{MONTH}/movie_release.json`. Updated every 6 hours. Also covers DVD releases.
# OAuth flow
Source: https://api.simkl.org/api-reference/oauth
Authorize a user, get a code, exchange it for an access token.
There's no reinventing the wheel here — the API uses OAuth 2.0.
Requesting user-associated information requires a `token` that needs to be included in all request headers made to the API.
To obtain the `client_id` and `client_secret`, please [create an app first](https://simkl.com/settings/developer/).
**Two domains, two roles.** OAuth uses **two different hosts** — easy to mix up, and the most common cause of "404 Not Found" during integration:
| Endpoint | Host | What it does |
| ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------- |
| `GET /oauth/authorize` | **`simkl.com`** | Browser-facing consent page. The user lands here, signs in, and approves your app. |
| `POST /oauth/token` | **`api.simkl.com`** | Server-to-server code exchange. Your backend posts the `code` here and gets back an `access_token`. |
If your authorize URL points at `api.simkl.com` you'll get a 404 — it has to be `simkl.com`.
To make calls on behalf of a user you have to obtain an `access_token`. To do this, first send the user to **`https://simkl.com/oauth/authorize`** to receive a `code`, then post it in JSON format to **`https://api.simkl.com/oauth/token`**. The response contains the `access_token`.
## STEP 1 — Authorize (`simkl.com`)
Open a URL like the one below in the user's browser or a Custom Tab (do **not** use a WebView on mobile):
```
https://simkl.com/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=http://yourdomain.com/oauth.html&state=RANDOM_CSRF_TOKEN&app-name=my-app-name&app-version=1.0
```
Simkl will redirect back to your `redirect_uri` with `?code=...` appended (and `&state=...` if you sent one).
Build this URL against `https://simkl.com` — **not** `https://api.simkl.com`. The API host has no `/oauth/authorize` page.
**Use `state` for CSRF protection.** Generate a random string before redirecting and store it on your session. When the redirect arrives, verify the `state` echoed back matches what you sent. If it doesn't, reject the request — someone else may have started a flow on your behalf.
**User denial doesn't return an `error=` parameter.** If the user clicks "No" on the consent screen, Simkl redirects to `/` on simkl.com — **not** back to your `redirect_uri` with `error=access_denied`. Treat any flow where the redirect never lands within a sensible timeout (e.g. 5 minutes) as a denial / cancellation and let the user retry.
## STEP 2 — Exchange code for token (`api.simkl.com`)
POST the `code` to `https://api.simkl.com/oauth/token` with your `client_id`, `client_secret`, `redirect_uri`, and `grant_type=authorization_code`.
**Both content-types and both credential locations work.** Simkl's `POST /oauth/token` accepts:
* `Content-Type: application/x-www-form-urlencoded` (the RFC 6749 §3.2 default) **or** `Content-Type: application/json` — pick whichever your HTTP client prefers
* Client credentials in the request body (`client_id` + `client_secret` parameters) **or** in the `Authorization: Basic` header (RFC 6749 §2.3.1) — both paths are honored
That means **off-the-shelf OAuth libraries work out-of-the-box** with no custom encoding or auth-method config. The two equivalent ways to call the token endpoint:
```bash form-encoded (RFC 6749 §3.2 default) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
```bash JSON body theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "YOUR_REDIRECT_URI",
"grant_type": "authorization_code"
}'
```
For library-specific examples (Python, Node, Java, Go, PHP), see [OAuth client libraries](/api-reference/oauth-libraries) — most are zero-config.
The successful response contains your `access_token`:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"access_token": "...",
"token_type": "bearer",
"scope": "public",
"expires_in": 157680000
}
```
`expires_in` is **5 years in seconds** — effectively infinite for any realistic session. There's no `refresh_token`; Simkl tokens are long-lived and the API has no refresh-token grant. If a 401 arrives before that lifetime elapses, the user revoked your app at [Connected Apps settings](https://simkl.com/settings/connected-apps/) — restart the flow from STEP 1.
**The authorization `code` is single-use.** As soon as you POST it to `/oauth/token`, the server deletes it — even if the exchange fails (network error, validation mismatch, etc.). If your exchange fails for any reason, don't retry with the same `code` — restart from STEP 1.
**Same user, same token.** If the same user runs the OAuth flow twice for your app — whether via the standard flow, PKCE, or PIN — Simkl returns the **same `access_token`** both times (it increments an internal usage counter but doesn't rotate the token). Storing the latest response is safe; you don't need to invalidate older tokens of your own because there aren't multiple ones.
**Public clients (mobile, SPA, browser extensions, desktop binaries) must not embed `client_secret`.** Anything compiled into the user's app should be considered leaked. Use the [Public PKCE flow](/api-reference/oauth-pkce) (`code_verifier` + `code_challenge`) instead — same browser-based UX, no secret required.
## See also
Per-platform recommendations, code samples, comparison across all three flows.
The variant for mobile, SPA, browser extensions, and desktop binaries — same browser UX, no `client_secret` required.
The alternative for TVs, consoles, watches, CLIs, and media-server plugins — no `client_secret` and no redirect required.
Endpoint reference — every accepted query parameter, including PKCE.
Endpoint reference — body fields, response shape, error codes, interactive playground.
# OAuth client libraries
Source: https://api.simkl.org/api-reference/oauth-libraries
Every popular OAuth library mints real Simkl tokens with default config. Copy-paste working code per language.
**Fastest path — skip the OAuth library entirely.** Simkl's OAuth flow is two HTTP steps:
1. **Redirect** the user to `https://simkl.com/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&state=...` — they approve in the browser, Simkl bounces back to your `redirect_uri` with `?code=...&state=...` in the query string.
2. **POST** that `code` to `https://api.simkl.com/oauth/token` with `client_id`, `client_secret`, `redirect_uri`, and `grant_type=authorization_code` in the body — the response is `{"access_token": "...", "token_type": "bearer", "scope": "public", "expires_in": 157680000}`. Send that token as `Authorization: Bearer ...` on every authenticated request.
That's it. **No refresh-token rotation, no scope dance** — Simkl tokens are long-lived (`expires_in` is 5 years) and only invalidate when the user revokes from [Connected Apps](https://simkl.com/settings/connected-apps/). For the full walkthroughs, see [OAuth 2.0 flow](/api-reference/oauth) (server-side with `client_secret`) or [PKCE flow](/api-reference/oauth-pkce) (mobile / SPA / desktop without `client_secret`).
Simkl's `POST /oauth/token` accepts both `application/x-www-form-urlencoded` (the RFC 6749 §3.2 default) and `application/json`, and reads client credentials from **either** the request body **or** an `Authorization: Basic` header (RFC 6749 §2.3.1). Discovery metadata is at [https://simkl.com/.well-known/oauth-authorization-server](https://simkl.com/.well-known/oauth-authorization-server) (RFC 8414) — modern libraries can auto-configure from it.
## Quick library status
Every library below was driven through the full browser-consent → real authorize code → real token mint flow against `api.simkl.com`. Each one returned a real `access_token` we then used to call `/users/settings` successfully.
| Language | Library | Default config status |
| -------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
| Python | [authlib](#python-authlib) | ✅ Works as-is |
| Python | [requests-oauthlib](#python-requests-oauthlib) | ✅ Works as-is |
| Python | [httpx-oauth](#python-httpx-oauth) | ✅ Works as-is |
| Node.js | [openid-client v6 (panva)](#node-openid-client-v6) | ✅ Works — pass `{algorithm: 'oauth2'}` to `discovery()` |
| Node.js | [oauth4webapi (panva)](#node-oauth4webapi) | ✅ Works — pass `{algorithm: 'oauth2'}` to `discoveryRequest()` |
| Node.js | [simple-oauth2](#node-simple-oauth2) | ✅ Works as-is |
| Node.js | [passport-oauth2](#node-passport-oauth2) | ✅ Works as-is |
| Node.js | [@badgateway/oauth2-client](#node-badgatewayoauth2-client) | ✅ Works as-is |
| Java | [Nimbus OAuth 2.0 SDK](#java-nimbus-oauth-2-0-sdk) | ✅ Works as-is |
| Java | [Spring Security OAuth2 Client](#java-spring-security-oauth2-client) | ✅ Works as-is |
| Java | [Google OAuth Client for Java](#java-google-oauth-client-for-java) | ✅ Works as-is |
| Java | [scribejava-core](#java-scribejava-core) | ✅ Works as-is |
| Go | [golang.org/x/oauth2](#go-golangorgxoauth2) | ✅ Works as-is |
| PHP | [league/oauth2-client](#php-leagueoauth2-client) | ✅ Works as-is |
| Any | [raw HTTP (curl, fetch, requests, …)](#raw-http) | ✅ Reference path |
Most libraries need no configuration beyond the two endpoint URLs and your credentials. The only outliers are `openid-client v6` and `oauth4webapi` — both default to OIDC discovery (`/.well-known/openid-configuration`), but Simkl is OAuth2-only, so they need an explicit `{ algorithm: 'oauth2' }` option to use [our RFC 8414 metadata endpoint](https://simkl.com/.well-known/oauth-authorization-server). One-line fix shown in their snippets.
## The wire format
```bash form-encoded (RFC 6749 §3.2 default) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
```bash JSON body theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "YOUR_REDIRECT_URI",
"grant_type": "authorization_code"
}'
```
```bash Basic Auth header (RFC 6749 §2.3.1) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
-u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
Success response:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"access_token": "<64-hex-char bearer token>",
"token_type": "bearer",
"scope": "public",
"expires_in": 157680000
}
```
The PKCE variant swaps `client_secret` for `code_verifier`.
***
## Python authlib
```python theme={"theme":{"light":"github-light","dark":"vesper"}}
# pip install authlib httpx
from authlib.integrations.httpx_client import OAuth2Client
client = OAuth2Client("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET",
token_endpoint="https://api.simkl.com/oauth/token")
token = client.fetch_token(
"https://api.simkl.com/oauth/token",
code="AUTHORIZATION_CODE_FROM_REDIRECT",
redirect_uri="YOUR_REDIRECT_URI",
)
print(token["access_token"])
```
***
## Python requests-oauthlib
```python theme={"theme":{"light":"github-light","dark":"vesper"}}
# pip install requests-oauthlib
from requests_oauthlib import OAuth2Session
session = OAuth2Session("YOUR_CLIENT_ID", redirect_uri="YOUR_REDIRECT_URI")
token = session.fetch_token(
"https://api.simkl.com/oauth/token",
code="AUTHORIZATION_CODE_FROM_REDIRECT",
client_secret="YOUR_CLIENT_SECRET",
)
print(token["access_token"])
```
***
## Python httpx-oauth
```python theme={"theme":{"light":"github-light","dark":"vesper"}}
# pip install httpx-oauth
import asyncio
from httpx_oauth.oauth2 import BaseOAuth2
client = BaseOAuth2(
"YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET",
authorize_endpoint="https://simkl.com/oauth/authorize",
access_token_endpoint="https://api.simkl.com/oauth/token",
)
token = asyncio.run(client.get_access_token(
code="AUTHORIZATION_CODE_FROM_REDIRECT",
redirect_uri="YOUR_REDIRECT_URI",
))
print(token["access_token"])
```
***
## Node openid-client v6
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// npm install openid-client
import * as client from "openid-client";
// {algorithm: 'oauth2'} → use /.well-known/oauth-authorization-server (RFC 8414).
// Default would call /.well-known/openid-configuration which Simkl is not.
const config = await client.discovery(
new URL("https://simkl.com"),
"YOUR_CLIENT_ID",
"YOUR_CLIENT_SECRET",
undefined,
{ algorithm: "oauth2" },
);
const callback = new URL("YOUR_REDIRECT_URI?code=AUTHORIZATION_CODE&state=YOUR_STATE");
const token = await client.authorizationCodeGrant(config, callback);
console.log(token.access_token);
```
***
## Node oauth4webapi
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// npm install oauth4webapi
import * as oauth from "oauth4webapi";
const issuer = new URL("https://simkl.com");
// {algorithm: 'oauth2'} → use RFC 8414 metadata, not OIDC.
const discoveryResp = await oauth.discoveryRequest(issuer, { algorithm: "oauth2" });
const as = await oauth.processDiscoveryResponse(issuer, discoveryResp);
const clientObj = { client_id: "YOUR_CLIENT_ID" };
const clientAuth = oauth.ClientSecretBasic("YOUR_CLIENT_SECRET");
const callbackUrl = new URL("YOUR_REDIRECT_URI?code=AUTHORIZATION_CODE&state=YOUR_STATE");
const params = oauth.validateAuthResponse(as, clientObj, callbackUrl, "YOUR_STATE");
const response = await oauth.authorizationCodeGrantRequest(
as, clientObj, clientAuth, params, "YOUR_REDIRECT_URI", oauth.nopkce,
);
const token = await response.json();
console.log(token.access_token);
```
***
## Node simple-oauth2
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// npm install simple-oauth2
import { AuthorizationCode } from "simple-oauth2";
const oauth = new AuthorizationCode({
client: { id: "YOUR_CLIENT_ID", secret: "YOUR_CLIENT_SECRET" },
auth: { tokenHost: "https://api.simkl.com", tokenPath: "/oauth/token" },
});
const result = await oauth.getToken({
code: "AUTHORIZATION_CODE_FROM_REDIRECT",
redirect_uri: "YOUR_REDIRECT_URI",
});
console.log(result.token.access_token);
```
***
## Node passport-oauth2
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// npm install passport-oauth2
import OAuth2Strategy from "passport-oauth2";
passport.use(new OAuth2Strategy(
{
authorizationURL: "https://simkl.com/oauth/authorize",
tokenURL: "https://api.simkl.com/oauth/token",
clientID: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET",
callbackURL: "YOUR_REDIRECT_URI",
},
(accessToken, refreshToken, profile, done) => done(null, { accessToken }),
));
```
***
## Node @badgateway/oauth2-client
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// npm install @badgateway/oauth2-client
import { OAuth2Client } from "@badgateway/oauth2-client";
const client = new OAuth2Client({
clientId: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET",
tokenEndpoint: "https://api.simkl.com/oauth/token",
authorizationEndpoint: "https://simkl.com/oauth/authorize",
});
const token = await client.authorizationCode.getTokenFromCodeRedirect(
"YOUR_REDIRECT_URI?code=AUTHORIZATION_CODE&state=YOUR_STATE",
{ redirectUri: "YOUR_REDIRECT_URI" },
);
console.log(token.accessToken);
```
***
## Java Nimbus OAuth 2.0 SDK
```java theme={"theme":{"light":"github-light","dark":"vesper"}}
// Maven: com.nimbusds:oauth2-oidc-sdk:11.21
import com.nimbusds.oauth2.sdk.*;
import com.nimbusds.oauth2.sdk.auth.*;
import com.nimbusds.oauth2.sdk.id.ClientID;
import java.net.URI;
TokenRequest req = new TokenRequest(
new URI("https://api.simkl.com/oauth/token"),
new ClientSecretBasic(new ClientID("YOUR_CLIENT_ID"), new Secret("YOUR_CLIENT_SECRET")),
new AuthorizationCodeGrant(
new AuthorizationCode("AUTHORIZATION_CODE_FROM_REDIRECT"),
new URI("YOUR_REDIRECT_URI")
)
);
AccessTokenResponse tok = TokenResponse.parse(req.toHTTPRequest().send()).toSuccessResponse();
System.out.println(tok.getTokens().getAccessToken().getValue());
```
***
## Java Spring Security OAuth2 Client
```yaml theme={"theme":{"light":"github-light","dark":"vesper"}}
# application.yml — discovery auto-configures the rest.
spring:
security:
oauth2:
client:
registration:
simkl:
client-id: YOUR_CLIENT_ID
client-secret: YOUR_CLIENT_SECRET
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
simkl:
issuer-uri: https://simkl.com
```
Spring fetches `/.well-known/oauth-authorization-server` from the `issuer-uri` and wires up `authorization_endpoint` + `token_endpoint` automatically. Default `client_secret_basic` works.
***
## Java Google OAuth Client for Java
```java theme={"theme":{"light":"github-light","dark":"vesper"}}
// Maven: com.google.oauth-client:google-oauth-client:1.38.0 + google-http-client-jackson2:1.45.3
import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest;
import com.google.api.client.http.BasicAuthentication;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
var req = new AuthorizationCodeTokenRequest(
new NetHttpTransport(), JacksonFactory.getDefaultInstance(),
new GenericUrl("https://api.simkl.com/oauth/token"),
"AUTHORIZATION_CODE_FROM_REDIRECT");
req.setRedirectUri("YOUR_REDIRECT_URI");
req.setClientAuthentication(new BasicAuthentication("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET"));
System.out.println(req.execute().getAccessToken());
```
***
## Java scribejava-core
```java theme={"theme":{"light":"github-light","dark":"vesper"}}
// Maven: com.github.scribejava:scribejava-core:8.3.3 + scribejava-java8:8.3.3
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.builder.api.DefaultApi20;
import com.github.scribejava.core.oauth2.clientauthentication.*;
class SimklApi extends DefaultApi20 {
public String getAccessTokenEndpoint() { return "https://api.simkl.com/oauth/token"; }
protected String getAuthorizationBaseUrl() { return "https://simkl.com/oauth/authorize"; }
@Override public ClientAuthentication getClientAuthentication() {
return HttpBasicAuthenticationScheme.instance();
}
}
var service = new ServiceBuilder("YOUR_CLIENT_ID")
.apiSecret("YOUR_CLIENT_SECRET")
.callback("YOUR_REDIRECT_URI")
.build(new SimklApi());
var token = service.getAccessToken("AUTHORIZATION_CODE_FROM_REDIRECT");
System.out.println(token.getAccessToken());
```
***
## Go golang.org/x/oauth2
```go theme={"theme":{"light":"github-light","dark":"vesper"}}
// go get golang.org/x/oauth2
import (
"context"
"golang.org/x/oauth2"
)
cfg := &oauth2.Config{
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
RedirectURL: "YOUR_REDIRECT_URI",
Endpoint: oauth2.Endpoint{
AuthURL: "https://simkl.com/oauth/authorize",
TokenURL: "https://api.simkl.com/oauth/token",
},
}
tok, _ := cfg.Exchange(context.Background(), "AUTHORIZATION_CODE_FROM_REDIRECT")
fmt.Println(tok.AccessToken)
```
***
## PHP league/oauth2-client
```php theme={"theme":{"light":"github-light","dark":"vesper"}}
// composer require league/oauth2-client
require __DIR__ . "/vendor/autoload.php";
$provider = new \League\OAuth2\Client\Provider\GenericProvider([
"clientId" => "YOUR_CLIENT_ID",
"clientSecret" => "YOUR_CLIENT_SECRET",
"redirectUri" => "YOUR_REDIRECT_URI",
"urlAuthorize" => "https://simkl.com/oauth/authorize",
"urlAccessToken" => "https://api.simkl.com/oauth/token",
"urlResourceOwnerDetails" => "https://api.simkl.com/users/settings",
]);
$token = $provider->getAccessToken("authorization_code", ["code" => "AUTHORIZATION_CODE_FROM_REDIRECT"]);
echo $token->getToken();
```
***
## Raw HTTP
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE_FROM_REDIRECT" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
***
### Other OAuth libraries (inferred from RFC compliance)
These libraries aren't in our live test harness, so the status below is read from each library's source/docs — not from a captured request to `api.simkl.com`. Most wrap one of the live-tested libraries above; the rest follow the same RFC defaults Simkl now accepts.
| Language | Library | Inferred |
| ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Python | Django allauth, python-social-auth, fastapi-users | Wraps `requests-oauthlib` — should work as-is. |
| Node.js | NextAuth.js / Auth.js, express-openid-connect | Wraps `openid-client` — works with `{algorithm: 'oauth2'}` discovery. |
| Node.js | grant (server middleware), oidc-client-ts | Default RFC config matches Simkl. |
| .NET | Duende.IdentityModel, OpenIddict (client), MS.AspNetCore.Authentication.OpenIdConnect | Default Basic Auth / discovery flows align with Simkl. |
| .NET | Microsoft.Identity.Client (MSAL) | Auth-code grant should work; broker / conditional access flows out of scope. |
| Server-side PHP | HWIOAuthBundle (Symfony) | Wraps `league/oauth2-client` — should work as-is. |
| Ruby | oauth2 gem, omniauth-oauth2 | Default `auth_scheme: :basic_auth` accepted. |
| Rust | oauth2 crate, openidconnect crate (ramosbugs) | Default `AuthType::BasicAuth` accepted. |
| Mobile | AppAuth-iOS / AppAuth-Android / AppAuth-JS / react-native-app-auth | RFC default — should work as-is. |
| Postman | Built-in OAuth 2.0 helper | Both "Send as Basic Auth header" and "Send client credentials in body" modes accepted. |
| OpenAPI Generator | Generated clients (all languages) | Generators emit RFC-conformant clients. |
If you hit a library not on this list, the sanity check is one HTTP capture: confirm the token POST hits `https://api.simkl.com/oauth/token`, sends `client_id` / `code` / `redirect_uri` / `grant_type` (and `client_secret` either in the body or in `Authorization: Basic`), and see what comes back. If the request looks RFC-shaped and you still hit an error, [let us know](/support) — we'd appreciate the capture so we can promote the library to the live matrix.
## See also
Confidential (server-side) flow.
Mobile / SPA / desktop flow without `client_secret`.
TV / console / CLI flow with a 5-character code.
# OAuth 2.0 + PKCE for public clients
Source: https://api.simkl.org/api-reference/oauth-pkce
OAuth 2.0 without `client_secret` for mobile apps, single-page apps, browser extensions, desktop binaries, and any other public client. Uses `code_verifier` + `code_challenge` to prove the redeeming client is the same one that initiated the flow.
PKCE — *Proof Key for Code Exchange*, [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) — is the secure way to run OAuth on a **public client**: any app where you can't safely embed a `client_secret`. The browser-based UX is identical to standard OAuth; the only difference is that the `client_secret` is replaced by a one-time `code_verifier` + `code_challenge` pair the client generates locally.
Even if an attacker intercepts the redirect with the authorization `code`, they can't redeem it without the verifier — and the verifier never leaves your app until the final POST.
## Pick this flow if…
| Use PKCE | Use a different flow |
| ------------------------------------------------- | ------------------------------------------------------------------------------- |
| iOS, iPadOS, watchOS apps | Server-side web apps that can keep a secret → [OAuth 2.0](/api-reference/oauth) |
| Android, Wear OS apps | TVs, consoles, smart watches, CLI tools → [PIN flow](/api-reference/pin) |
| Single-page apps (React, Vue, Svelte, vanilla JS) | |
| Browser extensions (Chrome, Firefox, Edge) | |
| Desktop binaries (Electron, Tauri, native) | |
If you'd otherwise be embedding `client_secret` in a public binary, you want PKCE.
**Two domains, two roles.** OAuth uses **two different hosts** — easy to mix up, and the most common cause of "404 Not Found" during integration:
| Endpoint | Host | What it does |
| ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------- |
| `GET /oauth/authorize` | **`simkl.com`** | Browser-facing consent page. The user lands here, signs in, and approves your app. |
| `POST /oauth/token` | **`api.simkl.com`** | Server-to-server code exchange. Your backend posts the `code` here and gets back an `access_token`. |
If your authorize URL points at `api.simkl.com` you'll get a 404 — it has to be `simkl.com`.
## At a glance
Three steps, two HTTP calls:
| | Where | What you send | What you get |
| --------------------------- | --------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **1. Generate locally** | client | — | A random `code_verifier` (43–128 chars) and its SHA-256 `code_challenge` |
| **2. Authorize (browser)** | `simkl.com/oauth/authorize` | `client_id`, `code_challenge`, `code_challenge_method=S256`, `state` | After consent: a `?code=…&state=…` redirect |
| **3. Exchange (HTTP POST)** | `api.simkl.com/oauth/token` | `code`, `client_id`, `code_verifier` | A long-lived `access_token` |
Tokens last until the user revokes from [Connected Apps](https://simkl.com/settings/connected-apps/) — there's no refresh-token flow.
## Detailed flow
Locally, before any redirect:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// Pseudocode — use your platform's crypto APIs (see recipes below).
code_verifier = base64url(random(32 bytes)) // 43–128 unreserved chars
code_challenge = base64url(sha256(code_verifier)) // S256 (recommended)
```
**`code_verifier` requirements** ([RFC 7636 §4.1](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1)):
* 43–128 characters
* Only the *unreserved URI characters*: `A-Z`, `a-z`, `0-9`, and `- . _ ~`
* Cryptographically random — never reuse across flows
Simkl enforces all three at the token-exchange step — a verifier outside the length range or carrying any other character returns `401 secret_error` ("PKCE verification failed"). The error message intentionally matches what a wrong-but-well-formed verifier returns, so format probing reveals nothing.
**`code_challenge_method`** — Simkl supports two values:
| Method | How it works |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `S256` *(default, recommended)* | `code_challenge = base64url(sha256(code_verifier))` — protects the verifier even if the authorize URL leaks |
| `plain` | `code_challenge = code_verifier` — only acceptable on platforms without SHA-256 (essentially never in 2026) |
**`code_challenge_method` is case-sensitive.** Send exactly `S256` or `plain` — not `s256`, `sha256`, or any other variant. Anything outside the two-value enum is rejected immediately at the authorize step:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "error": "invalid_request",
"error_description": "code_challenge_method must be S256 or plain (RFC 7636 §4.3)" }
```
If you see that error, double-check the casing.
**Where to keep the verifier between step 1 and step 3:**
| Platform | Storage |
| ---------------------- | -------------------------------------------------------------------------------------------- |
| iOS / Android | In-process memory (or Keychain / EncryptedSharedPreferences if the flow may be backgrounded) |
| SPA | `sessionStorage` — survives the redirect, cleared on tab close |
| Desktop CLI / loopback | Process memory while the local HTTP server waits |
| Browser extension | `chrome.storage.session` (or the equivalent) |
Open a browser session to:
```
https://simkl.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=YOUR_APP_DEEP_LINK (optional with PKCE — see "Choose a redirect URI" below)
&code_challenge=YOUR_CODE_CHALLENGE
&code_challenge_method=S256
&state=YOUR_RANDOM_CSRF_TOKEN
&app-name=my-app-name
&app-version=1.0
```
Use a platform-secure browser session — **not** an embedded WebView:
* **iOS / iPadOS** — `ASWebAuthenticationSession`
* **Android** — Chrome Custom Tabs (or the newer Auth Tab on supported Chrome versions)
* **Desktop** — the user's system browser, with a localhost loopback redirect
* **SPA** — a normal full-page navigation
* **Browser extension** — `chrome.identity.launchWebAuthFlow` / `browser.identity.launchWebAuthFlow`
Simkl shows a consent screen. After approval, the user is redirected to your `redirect_uri` with `?code=AUTHORIZATION_CODE&state=YOUR_RANDOM_CSRF_TOKEN` appended.
**Always send `state` for CSRF protection.** Generate a random per-flow value (e.g. `crypto.randomUUID()`), store it alongside the verifier, and verify on the redirect that the echoed value matches. Especially important on public clients — your custom URI scheme could in principle be triggered by another app on the same device, and `state` is what proves the response is for the flow you initiated.
**User denial doesn't return an `error=` parameter.** If the user clicks "No" on the consent screen, Simkl redirects to `/` on simkl.com — **not** back to your `redirect_uri` with `error=access_denied`. Treat any flow where the redirect never lands within a sensible timeout (e.g. 5 minutes) as a denial / cancellation and let the user retry.
Once you receive the authorization `code`, POST to the token endpoint **with the verifier instead of `client_secret`**:
```json POST https://api.simkl.com/oauth/token theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"code": "AUTHORIZATION_CODE",
"client_id": "YOUR_CLIENT_ID",
"code_verifier": "YOUR_CODE_VERIFIER",
"redirect_uri": "YOUR_APP_DEEP_LINK",
"grant_type": "authorization_code"
}
```
**Both content-types and both credential locations work.** Simkl's `POST /oauth/token` accepts:
* `Content-Type: application/x-www-form-urlencoded` (the RFC 6749 §3.2 default) **or** `Content-Type: application/json` — pick whichever your HTTP client prefers
* Client credentials in the request body (`client_id` + `client_secret` parameters) **or** in the `Authorization: Basic` header (RFC 6749 §2.3.1) — both paths are honored
That means **off-the-shelf OAuth libraries work out-of-the-box** with no custom encoding or auth-method config. The two equivalent ways to call the token endpoint:
```bash form-encoded (RFC 6749 §3.2 default) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
```bash JSON body theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "YOUR_REDIRECT_URI",
"grant_type": "authorization_code"
}'
```
For library-specific examples (Python, Node, Java, Go, PHP), see [OAuth client libraries](/api-reference/oauth-libraries) — most are zero-config.
Notice: **no `client_secret`**. Simkl re-derives the challenge from the verifier you send and matches it against what you sent in step 2. If they match, you get back an `access_token`. If they don't, the request fails — that's the protection: an attacker who intercepted the redirect doesn't have the verifier.
**Successful response (200):**
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"access_token": "...",
"token_type": "bearer",
"scope": "public",
"expires_in": 157680000
}
```
`expires_in` is **5 years in seconds** — Simkl tokens are long-lived and there's no refresh-token grant. If a 401 ever arrives before that lifetime elapses, the user revoked your app at [Connected Apps settings](https://simkl.com/settings/connected-apps/); start a fresh PKCE flow.
**PKCE failure (401):**
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "secret_error",
"code": 401,
"message": "PKCE verification failed"
}
```
`secret_error` is also returned for a wrong `client_secret` on the confidential flow — distinguishable by the `message` field. **Don't retry the same `code` after this error** — any exchange attempt (success OR failure) consumes the code (RFC 6749 §4.1.2). Restart the flow from `/oauth/authorize` with a fresh verifier+challenge pair.
Treat the `access_token` as **long-lived** — store it securely and reuse it on every authenticated request:
```http theme={"theme":{"light":"github-light","dark":"vesper"}}
Authorization: Bearer YOUR_ACCESS_TOKEN
```
Where to store it:
| Platform | Storage |
| ----------------------- | ---------------------------------------------------------------- |
| iOS / iPadOS | Keychain |
| Android | EncryptedSharedPreferences or the Android Keystore |
| macOS / Windows / Linux | OS keychain (Keychain Access, Credential Manager, libsecret) |
| SPA | `httpOnly` cookie set by your backend — **never** `localStorage` |
| Browser extension | `chrome.storage.local` (or the equivalent) |
## Choose a redirect URI
PKCE works with any redirect URI scheme — but each option has trade-offs:
| Type | Example | Best for | Notes |
| ----------------------------- | --------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Custom URI scheme** | `myapp://oauth` | Mobile (iOS, Android) | Simplest. Risk: another app could in principle register the same scheme — PKCE mitigates this since the attacker can't redeem the code without your verifier. |
| **Universal Link / App Link** | `https://yourdomain.com/oauth` | Mobile (iOS 9+, Android 6+) | Cryptographically bound to your domain via `apple-app-site-association` / `assetlinks.json`. Strongest mobile option. |
| **HTTPS callback** | `https://yourdomain.com/callback` | SPA | Standard web. The page reads `?code=` from the URL and POSTs to the token endpoint. |
| **Loopback** | `http://127.0.0.1:8765/callback` | Desktop / CLI | Spin up a local HTTP server on a free port; Simkl redirects there. Use `127.0.0.1`, not `localhost`. |
Whichever you pick, the URI you send in step 2 must **match a URL registered in your [app settings](https://simkl.com/settings/developer/) byte-for-byte** (scheme, host, port, path, trailing slash, casing).
If you can't host a redirect target at all — TVs, consoles, CLI tools — use the [PIN flow](/api-reference/pin) instead. That's the browser-less alternative; the user enters a 5-character code at `simkl.com/pin` and your app polls for the result.
## Platform recipes
Full PKCE flow with `ASWebAuthenticationSession` and `CryptoKit`:
```swift theme={"theme":{"light":"github-light","dark":"vesper"}}
import AuthenticationServices
import CryptoKit
func base64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// Step 1 — verifier + challenge
let verifier = base64url(Data((0..<32).map { _ in UInt8.random(in: 0...255) }))
let challenge = base64url(Data(SHA256.hash(data: Data(verifier.utf8))))
let state = UUID().uuidString
// Step 2 — open ASWebAuthenticationSession
var components = URLComponents(string: "https://simkl.com/oauth/authorize")!
components.queryItems = [
.init(name: "response_type", value: "code"),
.init(name: "client_id", value: "YOUR_CLIENT_ID"),
.init(name: "redirect_uri", value: "myapp://oauth"),
.init(name: "code_challenge", value: challenge),
.init(name: "code_challenge_method", value: "S256"),
.init(name: "state", value: state),
.init(name: "app-name", value: "my-app-name"),
.init(name: "app-version", value: "1.0"),
]
let session = ASWebAuthenticationSession(
url: components.url!,
callbackURLScheme: "myapp"
) { callbackURL, _ in
guard let callbackURL,
let qs = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
qs.queryItems?.first(where: { $0.name == "state" })?.value == state,
let code = qs.queryItems?.first(where: { $0.name == "code" })?.value
else { return }
// Step 3 — exchange the code for a token
var req = URLRequest(url: URL(string: "https://api.simkl.com/oauth/token")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("my-app-name/1.0", forHTTPHeaderField: "User-Agent")
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"code": code,
"client_id": "YOUR_CLIENT_ID",
"code_verifier": verifier,
"redirect_uri": "myapp://oauth",
"grant_type": "authorization_code",
])
URLSession.shared.dataTask(with: req) { data, _, _ in
guard let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["access_token"] as? String
else { return }
// Save `token` to Keychain.
}.resume()
}
session.presentationContextProvider = self
session.start()
```
Full PKCE flow with Custom Tabs and OkHttp:
```kotlin theme={"theme":{"light":"github-light","dark":"vesper"}}
import android.util.Base64
import androidx.browser.customtabs.CustomTabsIntent
import okhttp3.*
import org.json.JSONObject
import java.security.MessageDigest
fun base64url(bytes: ByteArray): String =
Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
// Step 1 — verifier + challenge
val verifier = base64url((1..32).map { (0..255).random().toByte() }.toByteArray())
val challenge = base64url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
val state = java.util.UUID.randomUUID().toString()
// Persist verifier + state somewhere your redirect handler can read them.
// Step 2 — launch a Custom Tab
val authUri = Uri.parse("https://simkl.com/oauth/authorize").buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", "YOUR_CLIENT_ID")
.appendQueryParameter("redirect_uri", "myapp://oauth")
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.appendQueryParameter("app-name", "my-app-name")
.appendQueryParameter("app-version", "1.0")
.build()
CustomTabsIntent.Builder().build().launchUrl(context, authUri)
// Step 3 — in the activity that handles myapp://oauth?code=...&state=...
fun handleRedirect(intent: Intent) {
val data = intent.data ?: return
if (data.getQueryParameter("state") != savedState) return // CSRF check
val code = data.getQueryParameter("code") ?: return
val body = JSONObject(mapOf(
"code" to code,
"client_id" to "YOUR_CLIENT_ID",
"code_verifier" to savedVerifier,
"redirect_uri" to "myapp://oauth",
"grant_type" to "authorization_code",
)).toString()
val req = Request.Builder()
.url("https://api.simkl.com/oauth/token")
.header("User-Agent", "my-app-name/1.0")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
OkHttpClient().newCall(req).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
val token = JSONObject(response.body!!.string()).getString("access_token")
// Save token to EncryptedSharedPreferences.
}
override fun onFailure(call: Call, e: IOException) { /* handle */ }
})
}
```
Browser-native Web Crypto API — no dependencies:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// Step 1 — verifier + challenge (run once before redirecting)
const base64url = bytes =>
btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
));
const state = crypto.randomUUID();
// Persist verifier + state across the redirect.
sessionStorage.setItem("pkce_verifier", verifier);
sessionStorage.setItem("pkce_state", state);
// Step 2 — full-page redirect to the consent screen
const params = new URLSearchParams({
response_type: "code",
client_id: "YOUR_CLIENT_ID",
redirect_uri: location.origin + "/callback",
code_challenge: challenge,
code_challenge_method: "S256",
state,
"app-name": "my-app-name",
"app-version": "1.0",
});
location.href = `https://simkl.com/oauth/authorize?${params}`;
// Step 3 — on /callback after the redirect lands
const qs = new URLSearchParams(location.search);
if (qs.get("state") !== sessionStorage.getItem("pkce_state")) {
throw new Error("State mismatch — possible CSRF attempt");
}
const r = await fetch("https://api.simkl.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: qs.get("code"),
client_id: "YOUR_CLIENT_ID",
code_verifier: sessionStorage.getItem("pkce_verifier"),
redirect_uri: location.origin + "/callback",
grant_type: "authorization_code",
}),
});
const { access_token } = await r.json();
sessionStorage.removeItem("pkce_verifier");
sessionStorage.removeItem("pkce_state");
// Send `access_token` to your backend to set as an httpOnly cookie.
```
Loopback redirect with `requests`:
```python theme={"theme":{"light":"github-light","dark":"vesper"}}
import base64, hashlib, secrets, urllib.parse, webbrowser, http.server, threading
import requests
# Step 1 — verifier + challenge
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
# Step 2 — open the user's browser to the consent page
auth_url = "https://simkl.com/oauth/authorize?" + urllib.parse.urlencode({
"response_type": "code",
"client_id": "YOUR_CLIENT_ID",
"redirect_uri": "http://127.0.0.1:8765/callback",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
"app-name": "my-app-name",
"app-version": "1.0",
})
webbrowser.open(auth_url)
# Spin up a one-shot loopback server to capture the redirect.
code_holder: dict[str, str] = {}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
if qs.get("state", [None])[0] != state:
self.send_response(400); self.end_headers(); return
code_holder["code"] = qs["code"][0]
self.send_response(200); self.end_headers()
self.wfile.write(b"Done — you can close this tab.")
def log_message(self, *a): pass
server = http.server.HTTPServer(("127.0.0.1", 8765), Handler)
threading.Thread(target=server.handle_request, daemon=True).start()
# Block until the redirect has been handled (or the user cancels).
while "code" not in code_holder:
pass
# Step 3 — exchange
r = requests.post(
"https://api.simkl.com/oauth/token",
headers={"User-Agent": "my-app-name/1.0"},
json={
"code": code_holder["code"],
"client_id": "YOUR_CLIENT_ID",
"code_verifier": verifier,
"redirect_uri": "http://127.0.0.1:8765/callback",
"grant_type": "authorization_code",
},
)
access_token = r.json()["access_token"]
```
## Common pitfalls
Most common causes:
* **`code_verifier` doesn't match the original `code_challenge`** — you probably regenerated the verifier between step 2 and step 3, or stored/loaded it incorrectly (URL-encoded once but not the other, padded vs unpadded base64). Returns `401 secret_error` with `"PKCE verification failed"` in the message field.
* **Authorization code expired or already used** — codes are short-lived AND single-use (RFC 6749 §4.1.2). Any exchange attempt — success OR failure — consumes the code. If a previous attempt failed for any reason (wrong verifier, format error, network glitch after the request reached the server), the code is gone. Restart from `/oauth/authorize` with a fresh verifier+challenge.
* **`redirect_uri` not registered** — the URI you send in step 3 must be a registered URL for your app (same whitelist as step 2). Simkl doesn't currently enforce that step 2 and step 3 use the exact same string, but stricter OAuth implementations do — send the same value both times for forward-compat.
* **`code_challenge_method` was wrong-cased on authorize** — the authorize endpoint rejects anything outside `{S256, plain}` (case-sensitive) with `400 invalid_request`. Re-run the flow with `S256`.
PKCE requires **base64url with no padding** (RFC 4648 §5):
* Use `-` instead of `+`
* Use `_` instead of `/`
* Strip trailing `=` characters
Many crypto libraries default to standard base64 (with `+`, `/`, and `=`) and you have to opt into the URL-safe variant. If your verifier or challenge contains any of those three characters, the server-side comparison will silently fail and you'll get `PKCE verification failed`. The `base64url` helper at the top of every recipe above does the substitution explicitly.
Your `redirect_uri` parameter doesn't match a URL registered for your app in [developer settings](https://simkl.com/settings/developer/), or the redirect didn't make it back to your app (custom-scheme handler not registered, Universal Link not signed, etc.). Register the URI byte-for-byte, then pass the same string in step 2. If your app genuinely has no place to redirect (TV, console, CLI), use the [PIN flow](/api-reference/pin) instead.
Use `ASWebAuthenticationSession` (not `SFSafariViewController` or a `WKWebView`) — only `ASWebAuthenticationSession` lets your app receive the deep-link callback when the user returns. Set `callbackURLScheme` to your registered scheme.
Each authorization flow should use its own pair. If the user has two browser tabs trying to authorize in parallel, store the verifier under a per-flow key (e.g. `pkce_verifier_` where `state` is also passed to `/authorize` and echoed back).
If your verification rejects a redirect because `state` doesn't match what you stored, treat it as a CSRF attempt — discard the `code` and don't proceed to the exchange. Most often this is a real bug (you forgot to persist `state` across the redirect, or you're reading it from the wrong storage), but it can also indicate that another app on the device intercepted your scheme. Restart the flow with a fresh verifier+state pair.
Append these to **every** request URL — both public catalog calls and authenticated user calls:
```
/endpoint?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
```
| Parameter | Required | Value |
| ------------- | -------- | --------------------------------------------------------------------------------------- |
| `client_id` | always | Your `client_id` from [your developer settings](https://simkl.com/settings/developer/). |
| `app-name` | always | Short, lowercase identifier for your app (e.g. `plex-scrobbler`, `kodi-trakt-bridge`). |
| `app-version` | always | The current version of your app, e.g. `1.0`, `2.4.1`. |
These three parameters help us see which apps are using the API, debug issues you report, and route around outages. They're cheap to send — please always include them.
## See also
`GET /oauth/authorize` and `POST /oauth/token` — both used in this PKCE flow, with parameter combinations specific to public clients.
Big-picture comparison: OAuth 2.0 (confidential) vs OAuth 2.0 + PKCE (public) vs PIN (browser-less).
# PIN flow
Source: https://api.simkl.org/api-reference/pin
Device-friendly auth flow for TVs, consoles, CLIs, and other limited-input devices.
This flow is designed for devices with limited input — media-center plugins, game consoles, smartwatches, smart TVs, command-line tools, system services. Your app shows a short alphanumeric code; the user enters it on their phone or computer; the device polls until they approve. **No `client_secret` and no redirect URI required.**
After the user authorizes, the device receives an `access_token` and behaves identically to an OAuth client from that point on.
## Steps
`GET /oauth/pin?client_id=…` returns:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"result": "OK",
"device_code": "DEVICE_CODE",
"user_code": "ABCDE",
"verification_uri": "https://simkl.com/pin",
"verification_url": "https://simkl.com/pin",
"expires_in": 900,
"interval": 5
}
```
Show `user_code` to the user. `expires_in` is 15 minutes; `interval` is 5 seconds (your polling cadence).
`device_code` is returned as the literal string `"DEVICE_CODE"` — it's a placeholder field kept for OAuth 2.0 Device Authorization Grant response-shape compatibility. Clients only need `user_code`. You can ignore `device_code` entirely.
The response also includes a `verification_url` key with the same value, kept as an alias. Read `verification_uri` — that's the RFC 8628 §3.2 spelling.
Tell the user: *"Go to [simkl.com/pin](https://simkl.com/pin/) and enter `ABCDE`."* Render the code in a large, easy-to-read style — it's typed by hand on a phone.
`GET /oauth/pin/{USER_CODE}?client_id=…` every `interval` seconds. Two response shapes:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
// Still pending — keep polling
{ "result": "KO", "message": "Authorization pending" }
// User approved — stop polling, store access_token
{ "result": "OK", "access_token": "..." }
```
Respect the returned `interval` (5 seconds). Polling faster won't help — the user enters their PIN at human speed. Once `expires_in` (15 minutes) elapses, the `user_code` is dead; request a fresh one and restart.
**Stop polling as soon as you receive the `access_token`.** After successful authorization the server deletes the code; if you keep polling on the deleted (or any unknown) `user_code`, this endpoint falls through to the *create-a-new-code* branch and you'll get back the same shape as `GET /oauth/pin` — including a brand-new `user_code` different from the one you polled. Detect any response containing `device_code` as "the original code is gone" and stop.
Save the `access_token` securely. From here on, the device works like any OAuth client — send `Authorization: Bearer ` on every authenticated request. Tokens are **long-lived** — the token-mint response advertises `expires_in: 157680000` (about 5 years), and there's no refresh-token grant. They only stop working when the user revokes your app from [Connected Apps settings](https://simkl.com/settings/connected-apps/); on the next 401, restart the PIN flow.
## Why PIN vs OAuth?
| | PIN | OAuth 2.0 |
| ------------------------- | ------------------------------------------------------------ | ---------------------------------------------- |
| **Best for** | TVs, consoles, watches, CLI tools, media-server plugins, IoT | Mobile apps, web apps, desktop apps |
| **Needs `client_secret`** | No | Yes (or PKCE for public clients) |
| **Needs `redirect_uri`** | No | Yes (or PKCE-only with no registered redirect) |
| **User experience** | App shows code → user types it on phone | Tap login → browser → approve → back to app |
| **Time to token** | 30 seconds – 2 minutes | \~5 seconds |
See [Choose a flow](/api-reference/auth) for the full per-platform comparison and code samples.
## See also
Platform-by-platform recommendations, side-by-side comparison, common pitfalls.
The alternative for browsers, mobile, and desktop — token in \~5 seconds.
Endpoint reference — request a `user_code`.
Endpoint reference — poll for the access token.
# How playbacks work
Source: https://api.simkl.org/api-reference/playback
Saved pause points across devices. When users pause or stop watching before 80%, Simkl saves their position so any signed-in device can resume.
A **playback** is a saved pause point. When a user pauses watching (`/scrobble/pause`) or stops before 80% progress (`/scrobble/stop` with `progress < 80`), Simkl persists the position so the user can resume from any signed-in device. Playbacks are how Simkl powers "Continue Watching" UIs.
This page is a reference index. The lifecycle, cross-device flow, and full scrobble integration live in the [Scrobble guide](/guides/scrobble).
**Playbacks are NOT the user's watch history.** A playback is a temporary "where I left off" record. It does **not** put the title on the user's watched list, does **not** count toward completion stats, and disappears once the user finishes the item, deletes it, or the [retention window](#retention-by-plan) expires.
To actually mark an item as **watched**, use one of:
* [`POST /sync/history`](/api-reference/simkl/add-to-history) — adds the item to the watched library directly. No scrobble session required.
* [`POST /scrobble/stop`](/api-reference/simkl/scrobble-stop) with `progress ≥ 80` — finishes a live scrobble session and writes a watch-history entry in the same call.
* [`POST /scrobble/checkin`](/api-reference/simkl/scrobble-checkin) — fire-and-forget; Simkl auto-marks the item watched once the runtime elapses.
If you only need a "Mark as watched" button (no playback tracking), [`POST /sync/history`](/api-reference/simkl/add-to-history) is the simplest path — see the [Mark as watched guide](/guides/mark-as-watched).
Members can browse and clean up their saved playbacks at [simkl.com/my/history/playback-progress-manager/](https://simkl.com/my/history/playback-progress-manager/).
## What gets stored
| Trigger | Result |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /scrobble/pause` (any progress) | Saves a paused playback at the sent `progress`. |
| `POST /scrobble/stop` with `progress < 80` | Saves a paused playback at the sent `progress` (resumable). |
| `POST /scrobble/stop` with `progress ≥ 80` | **No playback saved** — item is written to watch history instead (same as [`POST /sync/history`](/api-reference/simkl/add-to-history)). |
| `POST /scrobble/checkin` | No playback saved (the runtime-extrapolated session lives elsewhere). |
Only one paused playback per show / movie / anime is kept. A new pause replaces the previous one for that title.
## Retention by plan
Saved playbacks are pruned automatically by a daily cleanup job:
| Plan | Retention |
| ---- | --------- |
| Free | 7 days |
| PRO | 30 days |
| VIP | 90 days |
After the retention window, the playback is deleted unconditionally and can no longer be resumed.
## Cross-device resume — the recipe
Device A pauses, Device B picks it up:
`POST /scrobble/pause` with the user's `access_token` and current `progress`. Simkl saves the position.
[`POST /sync/activities`](/api-reference/simkl/get-activities) returns a `playback` timestamp per media-type bucket. **Compare it to the value you saved on the previous sync** — if it hasn't moved, no new pause has happened and you can skip the next step.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"all": "2026-04-19T12:14:08Z",
"movies": { "playback": "2026-04-19T12:14:08Z", ... },
"tv_shows": { "playback": "2026-04-15T08:31:55Z", ... },
"anime": { "playback": "2026-04-12T19:02:11Z", ... }
}
```
Only when the `playback` timestamp moved: `GET /sync/playback` (or narrow with `/sync/playback/episodes` / `/sync/playback/movies`) returns the paused playbacks for this user. Save the new timestamp.
`POST /scrobble/start` with the same item and the saved `progress`. The session continues; the prior pause is automatically cleared.
**Don't poll [`/sync/playback`](/api-reference/simkl/get-playback-sessions) on a timer.** Always gate refetches on the `playback` timestamp from [`/sync/activities`](/api-reference/simkl/get-activities) — that endpoint is the cheap "is anything new?" check. Idle accounts won't trip the gate, so quota stays free for active users. The same pattern applies to every Sync surface (history, watchlist, ratings); see the [Sync guide](/guides/sync) for the full strategy.
Both devices use the same `access_token` — the playback is stored per-user, not per-device.
## Endpoints
`GET /sync/playback` — list saved paused playbacks for a user (or narrow with `/sync/playback/:type` where `:type` is `episodes` or `movies`). Filter by `date_from`, `date_to`, `hide_watched`, `limit`.
`DELETE /sync/playback/:id` — remove a saved playback by its ID.
## Item shape
Each playback in the response includes:
* `id` — 64-bit integer playback ID (use this with the DELETE endpoint)
* `progress` — percentage 0-100 (e.g. `42`, `75.5`)
* `paused_at` — ISO-8601 UTC timestamp
* `type` — `"episode"` or `"movie"`
* For episodes: `episode.{season, number, title}` plus `tvdb_season` / `tvdb_number` for anime
* The container object: `show` (TV episodes), `anime` (anime episodes), or `movie`. Each carries `title`, `year`, and `ids`.
## Related
`POST /sync/history` — the canonical "mark as watched" endpoint. Use this (not playback) when you want a title on the user's watched library.
Pick the right write endpoint when you don't need real-time playback tracking.
The full scrobble lifecycle, including how pause/stop create playbacks.
API reference index for the four scrobble endpoints.
`POST /sync/activities` — the "is anything new?" gate. Check the `playback` timestamp before refetching.
The activities-driven refresh strategy applied across all user data.
# About Ratings
Source: https://api.simkl.org/api-reference/ratings
Where to read Simkl's community ratings, IMDb / MAL scores, drop rates, and rank — for any title in the catalog or just the items in a user's watchlist.
## Single-title ratings — use the detail endpoint
Per-title ratings (Simkl community average + votes, IMDb rating, MAL
rating + rank) are returned **as part of the catalog detail
endpoints**. There's no separate read-only "ratings by ID" endpoint —
the same data lives inside the `ratings` field of every detail
response.
| Have | Use |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A Simkl ID** | [`GET /movies/{id}`](/api-reference/simkl/get-movie) · [`GET /tv/{id}`](/api-reference/simkl/get-tv-show) · [`GET /anime/{id}`](/api-reference/simkl/get-anime). All three are Cloudflare-cached, so repeat reads are near-free. |
| **An external ID** (IMDb / TMDB / TVDB / MAL / AniDB / …) | First resolve to a Simkl ID via [`GET /redirect`](/api-reference/simkl/redirect) (returns a `301` with the canonical Simkl URL), then call the detail endpoint above. The redirect itself is a tiny 301 with no body; the detail call hits the CDN cache. |
The `ratings` block on every detail response looks like:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"ratings": {
"simkl": { "rating": 8.6, "votes": 11487 },
"imdb": { "rating": 8.8, "votes": 2817264 },
"mal": { "rating": 8.6, "votes": 3007340, "rank": 99 }
}
}
```
Each provider key is present **only when Simkl has data on file**.
Live-action titles typically carry `simkl + imdb`; modern anime carry
`simkl + mal`; classic anime sometimes carry all three.
## Bulk ratings for a user's watchlist
If you need the Simkl community rating + drop rate for **every title
in the user's watchlist** (one type at a time), use:
`?user_watchlist=watching` (or `plantowatch`, `hold`, `completed`, `dropped`, comma-separated, or `1` for all). Returns an array of `{id, simkl: {rating, votes, droprate}}` for every item in those watchlist buckets. Requires a Bearer token. Useful for "decide what to watch next from my plantowatch list" UIs.
For the user's **own** ratings (the 1-10 scores they've personally
assigned), use the [Sync endpoints](/api-reference/sync) under
`/sync/ratings`.
## Aggregate ratings on `simkl.com`
Public catalog pages on `simkl.com` (e.g. [`/movies/472214/inception`](https://simkl.com/movies/472214/inception)) render the same ratings block plus the user's reactions and reviews. If your app wants to deep-link rather than render, see the [Deep-linking guide](/guides/deep-linking).
# Redirect & deep-linking
Source: https://api.simkl.org/api-reference/redirect
Two jobs: link from anywhere into the right Simkl page (no API call), or resolve any external ID to a Simkl ID at the lowest possible cost.
`GET /redirect` is a passive helper that **`301`-redirects** to a Simkl URL given any combination of identifiers. It's designed for two distinct situations — pick the one that matches your job.
## Two use cases
You have an external ID or a title and you just want to send the user to the matching Simkl page (or trailer / share / mark-watched action). No `client_id`, no JSON parsing.
You have an IMDB / TMDB / TVDB / MAL ID and need the **Simkl ID** so you can call `/movies/:id`, `/tv/:id`, or `/anime/:id`. `/redirect` is the lowest-overhead way to get there — read one header, no JSON parsing.
Always returns `301 Moved Permanently` with a `Location` header and `Cache-Control: no-store`. The `type=show` value matches both `tv` and `anime`. Like every Simkl endpoint, requests must include the [required URL parameters](/conventions/headers#required-url-parameters) (`client_id`, `app-name`, `app-version`) and a `User-Agent` header. No `Authorization` token is needed except for `to=watched`, which signs the user in if they aren't already.
**Do not follow the 301.** This applies to **HTTP clients, scrapers, server-side fetchers, automated tools, AI agents, and LLM-driven workflows alike** — the information you need is in the **`Location` response header**, never in the destination body. The destination is one of:
* A human-facing simkl.com page (HTML),
* A YouTube trailer page,
* A `twitter.com/intent/tweet` URL,
* A `/oauth/authorize` URL (for `to=watched` flows when the user isn't signed in).
None of those contain API data. Fetching them wastes bandwidth, can break (CORS / auth / rate limits on the destination host), and gives you nothing useful. **The Simkl URL you want — and the `simkl_id` you can parse out of its path — is in the redirect target string, available without ever following the redirect.**
Configure your HTTP client to stop at the 301 and read the header:
| Tool / language | How to stop at the 301 |
| ----------------- | ------------------------------------------------------------------------------- |
| `curl` | `curl -I ` (HEAD request) or `curl --max-redirs 0 -s -D - ` |
| Python `requests` | `requests.get(url, allow_redirects=False)` then `r.headers['location']` |
| Python `httpx` | `httpx.get(url, follow_redirects=False)` |
| Node `fetch` | `fetch(url, { redirect: 'manual' })` then `r.headers.get('location')` |
| Node `axios` | `axios.get(url, { maxRedirects: 0, validateStatus: s => s === 301 })` |
| Go `net/http` | Set `client.CheckRedirect = func(...) error { return http.ErrUseLastResponse }` |
This is **the canonical way to use `/redirect`** — not a perf optimisation. Following the redirect defeats the endpoint's purpose.
**What to do after reading the `Location` header — pick one:**
* **You only need the Simkl ID.** Parse it out of the URL path and **stop here**. Don't call anything else. Example: `Location: https://simkl.com/tv/17465/game-of-thrones` → `simkl_id = 17465`. You're done.
* **You also need the full record** (title, overview, poster, fanart, ratings, trailers, etc.). Use the parsed Simkl ID to call the matching detail endpoint, which is **Cloudflare-cached by Simkl ID** — popular titles come straight from edge cache and are near-free:
* Movies → [`GET /movies/{simkl_id}`](/api-reference/simkl/get-movie)
* TV shows → [`GET /tv/{simkl_id}`](/api-reference/simkl/get-tv-show)
* Anime → [`GET /anime/{simkl_id}`](/api-reference/simkl/get-anime)
* Episode lists → [`GET /tv/episodes/{simkl_id}`](/api-reference/simkl/get-tv-episodes) or [`GET /anime/episodes/{simkl_id}`](/api-reference/simkl/get-anime-episodes)
Two HTTP requests max (one to `/redirect` for the ID, one to the cached detail endpoint for the data). Never follow the 301 from `/redirect` itself.
## Use case 1 — Link to Simkl when you don't have a Simkl ID
You're rendering a clickable link in a newsletter, a browser-extension menu, a "Share" button, or any external surface, and you'd rather not call the JSON API yourself. Hand `/redirect` whatever identifier you have on hand and it sends the user to the right place.
### Action modes (`to=`)
| Mode | Redirects to |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `simkl` *(default)* | The Simkl page for the resolved item. |
| `trailer` | The trailer URL (typically YouTube). |
| `twitter` | A `twitter.com/intent/tweet` URL with the title and a Simkl link prefilled. |
| `watched` | Marks the item watched on the user's account. If the user isn't signed in, Simkl first redirects through `/oauth/authorize`, then performs the action and lands them on the page. |
### Recipes
```bash IMDB → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&imdb=tt0944947&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/tv/17465/game-of-thrones
```
```bash TMDB → Simkl page (movie) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&type=movie&tmdb=27205&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/movies/472214/inception
```
\`\`\`bash TMDB → Simkl page (TV — `type` is required)
curl -I "[https://api.simkl.com/redirect?to=simkl\&type=tv\&tmdb=1399\&client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://api.simkl.com/redirect?to=simkl\&type=tv\&tmdb=1399\&client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0)" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: [https://simkl.com/tv/17465/game-of-thrones](https://simkl.com/tv/17465/game-of-thrones)
````
```bash TMDB → specific episode
curl -I "https://api.simkl.com/redirect?to=simkl&type=tv&tmdb=1399&season=1&episode=3&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/tv/17465/.../season-1/episode-3
````
```bash MAL → Simkl anime page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&mal=11757&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/anime/37226/sword-art-online
```
```bash Title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl&type=movie&title=Inception&year=2010&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/movies/472214/inception
```
```bash Trailer theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=trailer&imdb=tt0944947&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://www.youtube.com/watch?v=...
```
```bash Tweet a movie theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=twitter&type=movie&title=Inception&year=2010&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://twitter.com/intent/tweet?text=...
```
```bash Mark as watched (signs the user in if needed) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=watched&imdb=tt0944947&season=1&episode=3&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# 301 Location: https://simkl.com/oauth/authorize?... (returns to the action after login)
```
### Building the Simkl URL yourself
If you already have the Simkl ID and slug (returned in `ids.simkl_id` and `ids.slug` on every standard media object), you don't need `/redirect` at all — assemble the URL directly:
| Resource | URL pattern | | |
| ------------- | -------------------------------------------------------------------------- | ------ | --------- |
| Movie | `https://simkl.com/movies/{simkl_id}/{slug}` | | |
| TV show | `https://simkl.com/tv/{simkl_id}/{slug}` | | |
| Anime | `https://simkl.com/anime/{simkl_id}/{slug}` | | |
| TV season | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}` | | |
| TV episode | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}/episode-{M}` | | |
| Anime episode | `https://simkl.com/anime/{simkl_id}/{slug}/episode-{M}` | | |
| User profile | `https://simkl.com/{username}` | | |
| User stats | `https://simkl.com/{username}/stats/` | | |
| User library | \`[https://simkl.com/\{username}/\{tv](https://simkl.com/\{username}/\{tv) | movies | anime}/\` |
The `slug` is **technically optional, but always include it when you have it.** If you skip it, Simkl runs an extra title lookup and `301`-redirects to the slugged URL anyway — wasted server time on Simkl's side and an extra round-trip on yours. The `slug` is returned in `ids.slug` on every standard media object — store it alongside the Simkl ID and reuse.
***
## Use case 2 — Resolve an external ID to a Simkl ID
You have an external ID (IMDB, TMDB, TVDB, MAL, AniDB, AniList, Kitsu, etc.) and you need the **Simkl ID** so you can fetch the full record from `/movies/{id}`, `/tv/{id}`, or `/anime/{id}`. **This is the recommended path** — `/redirect` returns a tiny redirect with the Simkl ID baked into the URL, and the detail endpoints are aggressively cached on Cloudflare by Simkl ID.
### How `/redirect` resolves an ID
The response is a `301` with a `Location` header pointing at the canonical Simkl URL, e.g. `https://simkl.com/tv/17465/game-of-thrones`. Parse out `17465` and pass it to `/tv/17465` to get the full record from Cloudflare cache.
* **Tiny payload** — just HTTP headers, no JSON to parse.
* **Cached path** — the follow-up detail call (`/movies/{id}`, `/tv/{id}`, `/anime/{id}`) hits Cloudflare's edge cache, so it stays cheap for repeat lookups of the same title.
* **Same required params** as every Simkl endpoint — see [Headers and required parameters](/conventions/headers#required-url-parameters).
### How to extract the Simkl ID from the redirect
The `Location` header looks like one of:
```
https://simkl.com/movies/{simkl_id}/{slug}
https://simkl.com/tv/{simkl_id}/{slug}
https://simkl.com/anime/{simkl_id}/{slug}
```
The numeric segment immediately after `/movies/`, `/tv/`, or `/anime/` is the Simkl ID. Then call the summary endpoint of your choice with that ID.
### Recipes
```bash IMDB → Simkl ID → /tv/:id theme={"theme":{"light":"github-light","dark":"vesper"}}
PARAMS="client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0"
UA="my-app-name/1.0"
SIMKL_URL=$(curl -sI "https://api.simkl.com/redirect?to=simkl&imdb=tt0944947&$PARAMS" \
-H "User-Agent: $UA" \
| awk -v IGNORECASE=1 '/^location:/ {print $2}' | tr -d '\r\n')
SIMKL_ID=$(echo "$SIMKL_URL" | awk -F/ '{print $5}')
curl "https://api.simkl.com/tv/$SIMKL_ID?$PARAMS" \
-H "User-Agent: $UA"
```
```js IMDB → Simkl ID → /movies/:id theme={"theme":{"light":"github-light","dark":"vesper"}}
const PARAMS = `client_id=${CLIENT_ID}&app-name=my-app-name&app-version=1.0`;
const HEADERS = { 'User-Agent': 'my-app-name/1.0' };
const loc = await fetch(
`https://api.simkl.com/redirect?to=simkl&imdb=tt1375666&${PARAMS}`,
{ redirect: 'manual', headers: HEADERS }
).then(r => r.headers.get('location'));
// loc === "https://simkl.com/movies/472214/inception"
const simklId = loc.match(/\/(?:movies|tv|anime)\/(\d+)\//)[1];
const movie = await fetch(
`https://api.simkl.com/movies/${simklId}?${PARAMS}`,
{ headers: HEADERS }
).then(r => r.json());
```
```python MAL → Simkl ID → /anime/:id theme={"theme":{"light":"github-light","dark":"vesper"}}
import re, requests
PARAMS = {
'client_id': CLIENT_ID,
'app-name': 'my-app-name',
'app-version': '1.0',
}
HEADERS = {'User-Agent': 'my-app-name/1.0'}
# 1. Resolve MAL ID to a Simkl URL — read the Location header, no JSON parse
loc = requests.get(
'https://api.simkl.com/redirect',
params={**PARAMS, 'to': 'simkl', 'mal': 11757},
headers=HEADERS,
allow_redirects=False,
).headers['location']
# 2. Extract the Simkl ID from the URL path
simkl_id = int(re.search(r'/(?:movies|tv|anime)/(\d+)/', loc).group(1))
# 3. Pull the full anime record (summary endpoints auto-include extended data)
anime = requests.get(
f'https://api.simkl.com/anime/{simkl_id}',
params=PARAMS,
headers=HEADERS,
).json()
```
**Cache the resolved Simkl ID.** External IDs map to Simkl IDs once and rarely change. Store the mapping locally so the next request goes straight to `/movies/{id}` / `/tv/{id}` / `/anime/{id}` without a round-trip through `/redirect`.
***
## What you can pass
`/redirect` accepts a wide set of identifiers — pass any combination, the more the better:
Any of the [supported ID keys](/conventions/standard-media-objects#supported-id-keys) — `simkl`, `imdb`, `tmdb` (pair with `type=movie` or `type=tv` — TMDB has no anime type), `tvdb`, `mal`, `anidb`, `crunchyroll`, etc. Most stand alone.
`title=...&year=...&type=...`. Title-based fallback for when you have nothing else.
`season` (defaults to `1`) and `episode`. Setting either one excludes movies from the search.
`ep_title` is used when `to=twitter` to include the episode title in the tweet body.
## Endpoint reference
Full parameter list, response codes, and an interactive playground.
## Related
Where the `ids.simkl_id` and `ids.slug` fields come from.
Once you have the Simkl ID, fetch the full record from `/movies/{id}`, `/tv/{id}`, or `/anime/{id}` (Cloudflare-cached by ID).
`to=watched` first redirects through `/oauth/authorize` for unauthenticated users.
# How scrobbling works
Source: https://api.simkl.org/api-reference/scrobble
Report real-time playback — start, pause, stop, checkin — so Simkl tracks 'Watching now' and auto-marks items watched.
Scrobbling lets apps report **real-time playback events**. Use it when a user starts, pauses, or stops watching. If you just want a "Mark as watched" button without tracking playback, use [`POST /sync/history`](/api-reference/simkl/add-to-history) instead.
This page is a reference index. The lifecycle, decision tree, platform recipes, and gotchas live in the Scrobble guide:
Mermaid state diagram of the lifecycle, "checkin vs scrobble loop" decision tree, universal player-event mapping table, reference Scrobbler implementations in Python / JavaScript / Swift / TypeScript, seek/scrub handling, and a gotchas FAQ.
Pass as much data as you can (title, year, `ids`) so Simkl can detect the item reliably.
`progress` is a percentage from `0.00` to `100.00`. The input format is flexible (max 2 decimal places — `75`, `75.0`, `75.12` are all fine); responses are standardized (`75`, `75.12`, `45.5`). Scrobble IDs are 64-bit integers.
**Send scrobble events only on real user actions** — pressed Play, Paused, Stopped — with the current `progress` percentage. **Do not poll** `/scrobble/*` every few seconds or minutes. Simkl automatically advances the progress between events using the item's known runtime, so periodic re-posting wastes API quota and can trigger rate limits.
## What each endpoint does
| Endpoint | Effect |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`POST /scrobble/start`](/api-reference/simkl/scrobble-start) | Marks the user as **"Watching now"** on their profile. Does **not** mark the item as watched. |
| [`POST /scrobble/pause`](/api-reference/simkl/scrobble-pause) | Saves the current `progress` so the user can resume later. |
| [`POST /scrobble/stop`](/api-reference/simkl/scrobble-stop) | Ends the session. With `progress` ≥ 80%, marks the item watched; below that, the session is kept as a resumable pause. |
| [`POST /scrobble/checkin`](/api-reference/simkl/scrobble-checkin) | Like [`start`](/api-reference/simkl/scrobble-start), but Simkl **auto-marks the item watched when its computed progress reaches 100%** based on the item's runtime — no further calls needed from your app. Use this when you can't reliably hook into stop events. |
**Start ≠ watched.** A bare [`start`](/api-reference/simkl/scrobble-start) only puts the title in the user's "Watching now" banner. The item gets marked as watched only when:
* you call [`stop`](/api-reference/simkl/scrobble-stop) with progress ≥ 80%, **or**
* you used [`checkin`](/api-reference/simkl/scrobble-checkin) and the auto-tracked progress reaches 100%, **or**
* you separately call [`POST /sync/history`](/api-reference/simkl/add-to-history).
## How it works
Simkl stores **one active scrobble session per show/movie/anime**. Calling `/scrobble/start` **replaces any existing session** for that item and **clears previous pauses**. Paused sessions (created by `/scrobble/pause`, or by `/scrobble/stop` with progress under 80%) can be retrieved via [Get Playbacks](/api-reference/simkl/get-playback-sessions) and resumed by calling `/scrobble/start` again with the same item. To delete a paused session, use [Delete Playback](/api-reference/simkl/delete-playback).
### Lifecycle at a glance
Progress drives every transition. The 80% threshold is the only "magic number" you have to remember.
```
progress ≥ 80
┌──────────────► action: scrobble (marked watched)
start ──► pause ──► start ──► stop ┤
0% 45% 45% ? │ progress < 80
└──────────────► action: pause (resumable)
checkin ────────────────────────────► (no progress; auto-scrobbles at 100% from runtime)
```
* **Two ways to mark something watched.** Either drive [`start`](/api-reference/simkl/scrobble-start) → [`stop`](/api-reference/simkl/scrobble-stop) ≥ 80% yourself, or fire [`checkin`](/api-reference/simkl/scrobble-checkin) once and let Simkl auto-complete from the item's runtime.
* **`progress` is required for [`start`](/api-reference/simkl/scrobble-start) / [`pause`](/api-reference/simkl/scrobble-pause) / [`stop`](/api-reference/simkl/scrobble-stop)** (defaults to `0` if you omit it). On [`checkin`](/api-reference/simkl/scrobble-checkin) it is silently dropped server-side.
* **`simkl` ID alone is enough** for any of the four endpoints. Title + year + extra IDs help when you don't have a `simkl` ID and need Simkl's matcher to find the item.
### Typical flow
User presses Play in your app → `POST /scrobble/start` (or `/scrobble/checkin` if you want auto-completion). Title appears in the "Watching now" banner.
User pauses → `POST /scrobble/pause` with current `progress`. The session is kept as resumable.
User unpauses → `POST /scrobble/start` again with current `progress`. Same session continues.
User stops or finishes → `POST /scrobble/stop` with current `progress`. ≥ 80% marks watched; lower is kept as a pause.
**Use [`checkin`](/api-reference/simkl/scrobble-checkin) for fire-and-forget tracking** — when you have an item's runtime but no reliable "stop" event (e.g. some embedded players, casting flows). Simkl extrapolates progress from the start time + runtime and marks the item watched automatically when 100% is reached.
Members can view and manage their saved playbacks at [simkl.com/my/history/playback-progress-manager/](https://simkl.com/my/history/playback-progress-manager/).
### Action types in responses
The response `action` field tells you what Simkl did with your call:
| Value | When you'll see it |
| ---------- | ------------------------------------------------------------------------------------------- |
| `start` | Returned by `/scrobble/start` when beginning or resuming. |
| `checkin` | Returned by `/scrobble/checkin` — Simkl will auto-scrobble at 100% from the item's runtime. |
| `pause` | Returned by `/scrobble/pause`, or by `/scrobble/stop` when progress \< 80%. |
| `scrobble` | Returned by `/scrobble/stop` when progress ≥ 80% — item marked as watched. |
### Session management
* **Expiry timestamps:** start = now + remaining runtime; stop = now + 1 hour; pause = now.
* **Persistence:** sessions persist until manually removed or replaced by the next scrobble for that title. Retention by plan: Free 7 days, PRO 30 days, VIP 90 days.
* **Rate limiting:** one scrobble operation per user at a time (20-second lock).
* **Duplicate prevention:** `409` if you try to stop an already-completed session within 1 hour.
### Anime episode numbering
Simkl uses **AniDB** as the primary source for anime data, which numbers seasons/episodes differently from TMDB/TVDB. When you scrobble anime using TMDB season/episode numbers, Simkl maps them to the corresponding AniDB episode. Responses include both the Simkl/AniDB numbers (`season`, `number`) and the original TVDB numbers (`tvdb_season`, `tvdb_number`) for reference.
## Endpoints
`POST /scrobble/start` — show "Watching now"; resume a paused session.
`POST /scrobble/checkin` — auto-mark watched at 100% based on runtime.
`POST /scrobble/pause` — save progress so the user can resume.
`POST /scrobble/stop` — end session; ≥ 80% marks watched.
### Playbacks
`GET /sync/playback` — list saved paused playbacks (cross-device resume; optionally narrow with `/:type` where `:type` is `episodes` or `movies`).
`DELETE /sync/playback/:id` — remove a saved playback by ID.
# About Search
Source: https://api.simkl.org/api-reference/search
Look up items by ID, by title, by file name, or pull a random item.
The Search API lets you find items in Simkl's catalog. All endpoints accept a `client_id` only — no user `token` required — and return [Standard Media Objects](/conventions/standard-media-objects).
**Got an external ID already?** Don't search — use [`/redirect`](/api-reference/redirect) to resolve it to a Simkl ID, then fetch the full record from `/movies/{id}`, `/tv/{id}`, or `/anime/{id}` (Cloudflare-cached by Simkl ID, much cheaper for repeat lookups). Search endpoints are for cases where you only have a title string, a file name, or want a random pick.
| Endpoint | What it does |
| --------------------- | ----------------------------------------------------------------------- |
| `GET /search/{type}` | Free-text search across `movie`, `tv`, or `anime`. |
| `POST /search/file` | Identify a movie or episode from a file name (great for media servers). |
| `POST /search/random` | Pick a random item, optionally filtered by genre, year, rating, etc. |
`GET /search/{type}` — search by title.
`POST /search/file` — identify content from a file name.
`POST /search/random` — pick a random item.
# Add Ratings
Source: https://api.simkl.org/api-reference/simkl/add-ratings
/openapi.json post /sync/ratings
Apply user ratings (1-10) to movies, shows, or anime. Same auth model and batching rules as the rest of the [Sync API](/guides/sync). To **read** ratings back, use [`GET /sync/ratings/:type/:rating`](/api-reference/simkl/get-user-ratings) — see [Read side](#read-side) below.
#### Body shape
Top-level keys per media type, each carrying an array of items:
```json
{
"movies": [
{
"rating": 8,
"ids": {
"simkl": 53536
}
}
],
"shows": [
{
"rating": 9,
"ids": {
"tmdb": "1399"
}
}
],
"anime": [
{
"rating": 10,
"ids": {
"mal": "11757"
},
"rated_at": "2026-05-15T20:00:00Z"
}
]
}
```
Per-item fields:
| Field | Type | Required | Notes |
|---|---|---|---|
| `rating` | int 1-10 | yes | Out-of-range values (`0`, `11`, negatives) are **silently ignored** — see [Out-of-range](#out-of-range-ratings) below. |
| `ids` | object | yes | Any [supported ID](/conventions/standard-media-objects#supported-id-keys): `simkl`, `imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, `anilist`, `kitsu`, `livechart`, `anisearch`, `animeplanet`. Plus optional `title`+`year` fallback. |
| `rated_at` | ISO-8601 | no | Defaults to "now". Use to back-date imports from another tracker. |
Re-rating an item **overwrites** the prior value — no need to call `/sync/ratings/remove` first.
#### Auto-move side effect
Rating an item that's not yet on the user's list **auto-files it** based on airing status:
| Item kind | New status |
|---|---|
| Released movie | `completed` |
| Unreleased / upcoming movie | `plantowatch` |
| Single-episode show | `completed` |
| Multi-episode show or anime (any other case) | `watching` |
The corresponding list timestamp on [`/sync/activities`](/api-reference/simkl/get-activities) bumps, so a rated item shows up in the next `date_from` delta even though the user only rated it. Treat the delta as authoritative.
#### Response (201 Created)
```json
{
"added": {
"movies": 1,
"shows": 1,
"statuses": [
{
"request": {
"rating": 8,
"ids": {
"simkl": 53536
}
},
"response": {
"status": "completed"
}
}
]
},
"not_found": {
"movies": [],
"shows": []
}
}
```
**`added.anime` does NOT exist.** Anime items are folded into the `shows` counter — apps must not look for a separate `anime` slot in `added` or `not_found`. If you need to know which items landed, walk `added.statuses[]` (the `request.ids` echo back what you sent).
`response.status` per item is the watchlist status the auto-move applied (`completed`, `watching`, `plantowatch`, etc.) — useful for updating local UI without a follow-up `/sync/activities` poll.
#### Out-of-range ratings
Any `rating` outside 1-10 (including `0`, `11`, `-1`, `100`) is **silently rejected** — the item lands in `not_found.` and the HTTP status is still `201`. No `400` is returned; the rejection is reported in the response body, not the status code. **Clients must validate client-side**; never trust that a 2xx response means the rating was applied. Always inspect `added.statuses[]` (or `not_found`) to confirm.
#### Read side
`GET /sync/ratings` returns every rated item across all types in one response, keyed by media type:
```json
{
"movies": [ { ..., "user_rating": 8, "user_rated_at": "2026-05-13T..." } ],
"shows": [ { ..., "user_rating": 9, ... } ],
"anime": [ { ..., "user_rating": 10, ... } ]
}
```
Each item carries the standard watchlist record (status, episode counts, dates) plus `user_rating` (int 1-10 or `null`) and `user_rated_at` (ISO-8601 UTC or `null`).
Read ratings back with [`GET /sync/ratings/:type/:rating`](/api-reference/simkl/get-user-ratings). For **public catalog** ratings (the community average + IMDb/MAL score for any title, no token required), the rating data is in the per-title detail endpoints — [`GET /movies/:id`](/api-reference/simkl/get-movie), [`GET /tv/:id`](/api-reference/simkl/get-tv-show), [`GET /anime/:id`](/api-reference/simkl/get-anime) — under the `ratings` field. Resolve external IDs first via [`GET /redirect`](/api-reference/simkl/redirect).
#### Removing a rating
Use [`POST /sync/ratings/remove`](/api-reference/simkl/remove-ratings) with the same body shape minus the `rating` field (the value is ignored on remove). Removing the rating does **not** remove the item from the user's watchlist — only the score is cleared.
#### Rate alongside a watch event
If you're recording a watch event and want to attach a rating in the same call, use [`POST /sync/history`](/api-reference/simkl/add-to-history) — it accepts a `rating` field per item. One round-trip instead of two.
Two-phase model (initial pull -> activities-checked delta loop), `date_from` semantics, deletion reconciliation, edge cases, and reference implementations in Node and Python.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Add to History
Source: https://api.simkl.org/api-reference/simkl/add-to-history
/openapi.json post /sync/history
Record watch events. The unit is the **watch event** — adding "I watched The Walking Dead S01E01 at 8pm" — not list membership (use [`POST /sync/add-to-list`](/api-reference/simkl/add-to-list) for that, or set `status` per-item here to do both at once).
**You don't need a Simkl ID.** Same as `/sync/add-to-list` — the server resolves any combination of identifiers (`imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, `anilist`, `kitsu`) plus `title` + `year`. See [Standard media objects → Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list, and the [/sync/add-to-list ID-resolution table](/api-reference/simkl/add-to-list) for per-slot semantics.
#### Granularity — when does the server expand "implicit all"?
The body shape determines whether you mark a single episode, a season, or a whole show.
**One movie** — single movie completion.
```json
{ "movies": [{ "ids": {...} }] }
```
**Whole show** — every episode marked. *"I finished this whole series."* Send `status: "completed"` with no `seasons` / `episodes`.
```json
{ "shows": [{ "ids": {...}, "status": "completed" }] }
```
**Whole season** — every episode of one season. *"I finished season 2."* Send `seasons[]` without an inner `episodes`.
```json
{ "shows": [{ "ids": {...}, "seasons": [{ "number": 2 }] }] }
```
**Specific episodes only** — per-event scrobbling, manual tick-off.
```json
{
"shows": [{
"ids": {...},
"seasons": [{
"number": 1,
"episodes": [{ "number": 1 }, { "number": 2 }]
}]
}]
}
```
**Top-level `episodes[]` shorthand** — auto-wraps to `seasons: [{ number: 1, ... }]`. Useful for anime sequential numbering and single-season shows.
```json
{ "shows": [{ "ids": {...}, "episodes": [{ "number": 1 }] }] }
```
The response always reports the actual count of episodes affected in `added.episodes`, so apps can verify the server expanded correctly.
#### Memo-only updates (and "add to watchlist + memo" in one call)
This endpoint is the **only way to set a memo on an item.** [`POST /sync/add-to-list`](/api-reference/simkl/add-to-list) accepts the `memo` field in the request body and echoes it back in the response, but **does not persist it** — silently discarded.
To set or update a memo without recording a watch event, send `ids` + `status` + `memo`:
```json
{
"movies": [{
"ids": { "simkl": 53536 },
"status": "plantowatch",
"memo": { "text": "Remind me why I added this", "is_private": true }
}]
}
```
Two behaviors worth knowing:
- **Memos attach to watchlist items only.** The item has to be in one of the five statuses (`watching` / `plantowatch` / `hold` / `dropped` / `completed`) for the memo to stick. Sending `status` makes this explicit.
- **The endpoint auto-adds items.** If the item isn't on the user's watchlist yet, this same call creates the watchlist row at the specified `status` AND saves the memo in one shot. `added.movies` / `added.shows` reports the count of newly-added items (`0` when the item was already there and you only changed memo/status).
To read memos back, call `GET /sync/all-items?memos=yes` — see the [Sync guide](/guides/sync).
#### Per-item options
| Field | Type | Notes |
|---|---|---|
| `watched_at` | ISO-8601 string | Pin the watch event to a specific time. Defaults to request time. |
| `added_at` | ISO-8601 string | Override when the item was added to the watchlist (rarely used outside backups). |
| `status` | string | Set the watchlist status (`watching`/`plantowatch`/`hold`/`completed`/`dropped`) in the same call. Combine with `rating` to do "watched + rate + status" in one request. |
| `rating` | int 1-10 | Rate the item alongside the watch event. Same effect as a separate `POST /sync/ratings` call. |
| `memo` | `{ "text": string, "is_private": bool }` | User memo, max 140 chars. `is_private: false` shows the memo on the user's public profile + activity feed; `true` keeps it self-only. Read-back requires `/sync/all-items?memos=yes`. |
| `is_rewatch` | bool | Force the rewatch path on this item even if the server can't auto-detect (used by backup/restore tools). Requires `?allow_rewatch=yes` query param to take effect. |
| `use_tvdb_anime_seasons` | bool *(anime-only, optional)* | Default `false` (AniDB sequential — flat single-season). Set `true` to interpret `season`/`number` as TVDB per-season numbering. **Only needed when your source uses TVDB-style numbering AND the title is multi-season in TVDB** (e.g. Demon Slayer S2 Entertainment District). Single-season anime and AniDB-sequential inputs work without this flag. Use when syncing from Plex/Sonarr/Kodi/Jellyfin. |
#### Rewatches
Re-posting an already-watched episode is a **no-op by default** — the server detects the duplicate and skips. To insert a rewatch session, send `?allow_rewatch=yes` as a query parameter. The server then creates a separate rewatch row that doesn't double-count the original watch.
For backup/restore tools that always want to insert (even when the auto-detect heuristic can't fire), set `is_rewatch: true` per-item AND pass the query param.
#### Response: `added` and `not_found`
```json
{
"added": {
"movies": ,
"shows": ,
"episodes": ,
"statuses": [
{
"request": { /* echo of input item, with type added */ },
"response": {
"status": "completed",
"simkl_type": "tv" | "anime" | "movie",
"anime_type": "tv" | "movie" | "ova" | null
}
}
]
},
"not_found": {
"movies": [...],
"shows": [...],
"episodes": [...]
}
}
```
**`added.statuses[*].response.simkl_type`** tells you which catalog the item resolved to (useful when you sent ambiguous IDs — TMDB IDs can be either movie or tv on Simkl). Always inspect to know what got created.
**`not_found`** carries the verbatim input for items the resolver couldn't match (typo, fuzzy-title miss, ID not in Simkl's catalog yet). Apps should:
- Show "we couldn't track: …" UI for these
- Offer manual ID-entry fallback
- Don't infer success from the 201 status alone — branch on `not_found.movies.length === 0 && not_found.shows.length === 0 && not_found.episodes.length === 0`
#### Errors
`400 empty_field` if a per-item required field is missing. `400 wrong_parameter` for invalid enum values. Empty body `{}` returns 201 with zero counts (NOT 400) — that's a known asymmetry vs `/scrobble/start` which 400s on empty body.
Initial-pull-then-delta-loop pattern, `date_from` semantics, deletion reconciliation, Trakt/Letterboxd migration recipes.
#### When to use `/sync/history` vs `/sync/add-to-list`
| Goal | Endpoint |
|---|---|
| User finished watching → mark watched + rate + memo | **`/sync/history`** (carries all three in one shape) |
| User clicked "Add to Plan to Watch" button | **`/sync/add-to-list`** (status-only, no watch event) |
| Backfill from Trakt/Letterboxd/IMDb (events with timestamps) | **`/sync/history`** with `watched_at` per item |
| Bulk import a watchlist (no watch events) | **`/sync/add-to-list`** |
| Remove an item from the user's library | **[`/sync/history/remove`](/api-reference/simkl/remove-from-history)** |
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Add to Watchlist
Source: https://api.simkl.org/api-reference/simkl/add-to-list
/openapi.json post /sync/add-to-list
Move an item into one of the user's **Watchlist** statuses. The body's per-item `to` field selects the destination:
**Most apps prefer [`POST /sync/history`](/api-reference/simkl/add-to-history) for sync flows.** Use this endpoint (`/sync/add-to-list`) when your app has explicit "Add to Plan to Watch" / "Move to Hold" UI buttons — i.e. you're setting watchlist status without recording a watch event. For backfill from another tracker, scrobbling, or marking-watched UI, send to `/sync/history` instead (which carries `status`, `rating`, `memo`, AND the watch event in one shape). See the [Sync guide](/guides/sync) for the two-phase pull/delta pattern.
**This endpoint does not save memos.** If you include a `memo` field per item, the request **succeeds** and the memo is **echoed back** in the response — but the value is not persisted; reading the item with `?memos=yes` afterwards returns `memo: {}`. To set or update a memo, send the item to [`POST /sync/history`](/api-reference/simkl/add-to-history#memo-only-updates-and-add-to-watchlist-memo-in-one-call) with `ids` + `status` + `memo`. `/sync/history` also auto-adds the item to the watchlist if it wasn't there yet, so a single call covers both "add the item" and "set the memo".
| `to` value | Destination |
|---|---|
| `watching` | Currently watching. (For movies, automatically becomes `completed` since movies are atomic.) |
| `plantowatch` | Plan to watch. |
| `hold` | On hold. |
| `dropped` | Dropped. |
| `completed` | Completed. |
`to` is **per-item** (each entry in the array carries its own destination). The legacy top-level `to` shape is **not accepted** — the server returns `400 empty_field` (`Missed "to" parameter`) when `to` only appears at the top level.
**You don't need a Simkl ID.** The server resolves any combination of identifiers via its internal Search — pass whatever IDs your app already has and skip the `/redirect` lookup step. Identifier slots accepted on each item:
| Slot | Notes |
|---|---|
| `ids.simkl` | Simkl internal ID. Always wins when present. |
| `ids.imdb` | IMDb ID (e.g. `tt1375666`). Works for movies and shows. |
| `ids.tmdb` | TMDB ID. Works for movies and shows. |
| `ids.tvdb` | TVDB ID. Most-used canonical for TV in media-server stacks (Plex, Sonarr, Jellyfin, Kodi). |
| `ids.mal` / `ids.anidb` / `ids.anilist` / `ids.kitsu` | Anime catalogs. Send any/all you have. |
| `ids.slug` | URL slug — useful when you only have a Simkl-shaped link. |
| `title` + `year` (no `ids` at all) | Text fallback. Fuzzy match; ambiguous titles may miss — inspect `not_found` in the response. |
**Send everything you have.** The server picks the first identifier that resolves and accepts the extras. This is the canonical shape for migrations from another tracker (Trakt → Simkl, IMDb-list import, Letterboxd export, etc.) — just forward whatever the source carried.
```json
{
"movies": [
{
"to": "completed",
"title": "Inception",
"year": 2010,
"ids": {
"simkl": 472214,
"imdb": "tt1375666",
"tmdb": "27205"
}
}
],
"shows": [
{
"to": "watching",
"title": "Game of Thrones",
"year": 2011,
"ids": {
"simkl": 17465,
"slug": "game-of-thrones",
"imdb": "tt0944947",
"tmdb": "1399",
"tvdb": "121361"
}
}
]
}
```
Optional per-item fields: `watched_at`, `added_at`.
**To remove an item, use [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history).** This endpoint operates on the Watchlist (the five statuses above); the canonical un-track / delete-from-list path is `/sync/history/remove`, which writes to the same backing store and returns the same kind of result envelope. A legacy `to: "remove"` value is accepted by this endpoint for backwards compatibility, but it is **undocumented** and should not be used in new integrations — Simkl reserves the right to change its behavior without notice.
#### Response: `added` and `not_found`
The response always returns 201 (even on partial failures) with two arrays per media-type:
```json
{
"added": {
"movies": [{ "to": "completed", "ids": {...}, "type": "movie" }],
"shows": [{ "to": "watching", "ids": {...}, "type": "show" }]
},
"not_found": {
"movies": [{ "title": "Definitely Not A Real Movie", "year": 9999 }],
"shows": []
}
}
```
Items the server's resolver matched land in `added`. Items it couldn't match (typos, fuzzy title misses, IDs not in Simkl's catalog yet) land in `not_found` — verbatim copies of the input so you can show "we couldn't add: …" in your UI. **Always inspect both arrays after a bulk call.**
Errors: `400 empty_field` if `to` is missing on an item; `400 wrong_parameter` if `to` is not one of the values above.
#### Silent `to` rewrites
The server may downgrade your requested `to` value when an item isn't in a state where that status applies:
- **Movies** with `to: "watching"` → silently rewritten to `completed` (movies are atomic; you can't "be watching" a movie).
- **Shows** that aren't ready for `completed` (still airing, or pre-release) get rewritten to `watching` or `plantowatch` respectively, depending on whether any episode has aired.
The rewrites happen server-side; the consumer just sees the actual stored value in `added.[i].to`. There is **no error code** surfaced for the rewrite — the only way to detect it is to compare the value you sent against the value that came back.
> Note: this endpoint operates on the Simkl **Watchlist** (the five canonical statuses above). Custom user-created lists will get their own API in a future release.
Two-phase model (initial pull → activities-checked delta loop), `date_from` semantics, deletion reconciliation, edge cases, and reference implementations in Node and Python.
**Anime titles:** can go in either the `anime[]` array OR the `shows[]` array — both are accepted. The server normalizes anime into the response's `shows` array with `"type": "show"` per-item, since anime are TV-like in the cross-catalog data model. AniDB-specific IDs (`mal`, `anidb`, `anilist`, `kitsu`) belong inside each item's `ids` object regardless of which array it lives in. See [Anime under shows[]](/conventions/standard-media-objects#anime).
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Authorize a user
Source: https://api.simkl.org/api-reference/simkl/authorize
/openapi.json get /oauth/authorize
Step 1 of the OAuth 2.0 authorization-code flow. Redirect the user's browser to this URL on **simkl.com** (not api.simkl.com). Simkl shows a consent screen and, once the user approves, redirects to your `redirect_uri` with `?code=…`.
> ⚠️ **Do not use a WebView on mobile.** Use the system browser or a Custom Tab. WebViews are blocked for security reasons.
#### Parameters
| Param | Required | Notes |
|---|---|---|
| `response_type` | yes | Must be `code`. |
| `client_id` | yes | Your app's `client_id`. |
| `redirect_uri` | conditional | Required for confidential clients and for any app that has a redirect URI registered. Optional only when using **PKCE** *and* your app has no registered redirect URI — in that case Simkl completes the consent flow on simkl.com itself. When sent, must match the URL registered for the app **byte-for-byte**. |
| `state` | no | Random string you generate; echoed back to your `redirect_uri` for **CSRF protection**. Strongly recommended. |
| `code_challenge` | conditional | Required for **PKCE** (public clients without `client_secret`). Base64url-encoded SHA-256 of your `code_verifier`. See the [Public PKCE walkthrough](/api-reference/oauth-pkce). |
| `code_challenge_method` | no | `S256` (default, recommended) or `plain`. Case-sensitive — lowercase variants are silently ignored and your token exchange will then fail with `Wrong Secret`. |
The user is redirected to:
```
YOUR_REDIRECT_URI?code=AUTHORIZATION_CODE&state=YOUR_STATE
```
Exchange the `code` for an `access_token` via [`POST /oauth/token`](/api-reference/simkl/exchange-token). Codes are short-lived; exchange immediately.
Confidential-client (server-side) flow with `client_secret`.
Public-client (mobile / SPA / desktop) flow with `code_verifier` + `code_challenge`.
# Poll PIN code for the access token
Source: https://api.simkl.org/api-reference/simkl/check-pin
/openapi.json get /oauth/pin/{user_code}
Step 3 of the **PIN flow**. Poll this endpoint every `interval` seconds (returned in step 1, currently `5`) to learn whether the user has entered the code. **Stop polling when `expires_in` (currently `900` seconds) elapses** and prompt the user to restart.
#### Possible responses
| Body | Meaning |
|---|---|
| `{ "result": "OK", "access_token": "…" }` | User authorized. Save the token and stop polling. |
| `{ "result": "KO", "message": "Authorization pending" }` | Keep polling at the returned `interval`. |
After receiving the `access_token`, send it as `Authorization: Bearer ` on every authenticated request.
**Polling a code that doesn't exist (anymore) returns a fresh init response.** If you keep polling after a successful authorization, the server has already deleted the original `user_code` row and this endpoint falls through to the *create-a-new-code* branch — you'll get back the same shape as `GET /oauth/pin` (with a brand-new `user_code` different from the one you polled). Treat any response that contains `device_code` as "the original code is gone" and stop polling. The same thing happens for any unknown `user_code` (typos, expired codes that have been garbage-collected).
Device authorization for TVs, consoles, smart watches, and CLI tools — show a 5-character code, the user enters it at simkl.com/pin, the app polls for the access token.
# Delete Playback
Source: https://api.simkl.org/api-reference/simkl/delete-playback
/openapi.json delete /sync/playback/{id}
Removes a saved playback session by its `id`. **HTTP method is `DELETE`** — using `POST` or `GET` against this URL will not delete and will instead hit the list handler. Get the IDs from [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions).
#### Possible responses
| Status | `error` | When |
|---|---|---|
| `204` | — | Session deleted. |
| `404` | `empty` | The `id` is numeric but does not match any playback session for this user. |
| `404` | `url_failed` | The `id` segment is missing, `0`, or non-numeric (e.g. `notanumber`, `abc123`). |
**Use `DELETE` and pass a real positive integer id.** Calling `POST` or `GET /sync/playback/` does **not** delete anything — non-`DELETE` requests to this URL return the user's paused-playback list instead, the same shape as [`GET /sync/playback`](/api-reference/simkl/get-playback-sessions). Always explicitly send `DELETE`, and pass an `id` you got from [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions). `DELETE /sync/playback/0`, `DELETE /sync/playback` (no id), and `DELETE /sync/playback/` all return `404 url_failed`.
Real-time playback tracking — `/start`, `/pause`, `/stop` lifecycle, paused-playback resumption across devices, when scrobble auto-completes, and the difference between `/scrobble/checkin` (fire-and-forget) and `/scrobble/start` (active tracking).
# Exchange an authorization code for an access token
Source: https://api.simkl.org/api-reference/simkl/exchange-token
/openapi.json post /oauth/token
Step 2 of OAuth 2.0. POST your authorization `code` here to receive an `access_token`. The success response carries `{access_token, token_type: "bearer", scope: "public", expires_in: 157680000}` — about 5 years. Save the token securely. **No refresh token is issued**; if a 401 arrives before that lifetime (user revoked from [Connected Apps](https://simkl.com/settings/connected-apps/)), send the user back through `/oauth/authorize` for a fresh consent.
#### Wire format
The endpoint accepts both `application/x-www-form-urlencoded` (the RFC 6749 §3.2 default that most OAuth libraries use) and `application/json` — pick whichever your HTTP client prefers. Client credentials can be sent in **either** the request body (`client_id` + `client_secret`) **or** the `Authorization: Basic ` header (RFC 6749 §2.3.1). All four combinations are equivalent. **Off-the-shelf OAuth libraries work out-of-the-box** with default configuration; see [OAuth client libraries](/api-reference/oauth-libraries) for live-tested examples across every popular runtime.
Two flows share this endpoint, distinguished by which secret you send:
- **Confidential clients** (server-side web apps) send `client_secret` + `redirect_uri`.
- **Public clients** (mobile, SPA, desktop, browser extensions) send `code_verifier` instead — no secret required. See the [Public PKCE walkthrough](/api-reference/oauth-pkce).
#### Body fields
| Field | Required | Notes |
|---|---|---|
| `grant_type` | yes | Must be `authorization_code`. |
| `code` | yes | Authorization code returned to your `redirect_uri` (or, for PKCE-without-registered-URI, displayed on simkl.com). |
| `client_id` | yes (in body or Basic Auth header) | Your app's `client_id`. |
| `client_secret` | conditional | Confidential flow only. Mutually exclusive with `code_verifier`. May be sent in the body or in the `Authorization: Basic` header. |
| `code_verifier` | conditional | PKCE flow only. The original verifier you generated locally; Simkl re-derives the challenge and matches it against what you sent on `/oauth/authorize`. |
| `redirect_uri` | conditional | Required on the confidential flow (must match the URL registered for your app **byte-for-byte**). On PKCE, required only if you sent one to `/oauth/authorize` — and then it must match that one. |
#### Errors
All failures return JSON with an `error` field (and usually a `message` field too). 401 responses additionally carry an RFC 6750 §3 `WWW-Authenticate: Bearer realm="api.simkl.com", error="..."` header.
| Status | `error` | When |
|---|---|---|
| 403 | `empty_field` | A required body field is missing (`code`, `client_id`, `grant_type`, or both `client_secret`/`code_verifier`). |
| 403 | `redirect_failed` | `redirect_uri` doesn't match the URL registered for the app. |
| 401 | `secret_error` | Wrong `client_secret` (confidential flow) **or** PKCE verification failed (`message: "PKCE verification failed"`). |
| 401 | `grant_error` | The `code` is invalid, expired, or already used. Codes are single-use — restart from `/oauth/authorize`. |
Confidential-client (server-side) flow.
Public-client flow with `code_verifier`.
# Get Last Activities
Source: https://api.simkl.org/api-reference/simkl/get-activities
/openapi.json get /sync/activities
Returns the most recent update timestamps for each of the user's lists. **Always call this first** when syncing — compare against your last-saved timestamps and pull only the lists that have moved. This is the cheapest call in the API.
#### Top-level fields
| Field | Use |
|---|---|
| `all` | Latest update across every domain. Best first-level check. |
| `settings.all` | Updates to account settings (name, time zone, …) at https://simkl.com/settings/. |
| `tv_shows`, `anime`, `movies` | Per-domain timestamp groups. |
#### Per-domain timestamps
| Field | Meaning | Cheapest next call |
|---|---|---|
| `all` | Latest update in this domain. | — |
| `rated_at` | A rating was added, changed, or removed. | [`GET /sync/ratings/{type}/{rating}`](/api-reference/simkl/get-user-ratings) with `date_from` — only the changed ratings. Walkthrough: [Phase 2 — Continuous sync](/guides/sync#phase-2-continuous-sync). |
| `playback` | A paused playback was added, resumed, or cleared. | [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions) with `date_from` — only the changed sessions. |
| `plantowatch`, `watching`, `completed`, `hold`, `dropped` | Items moved into/out of these lists, or episodes were marked watched/unwatched. | [`GET /sync/all-items/{type}/{status}`](/api-reference/simkl/get-all-items) with `date_from` and `extended=full` — full delta of modified items. Walkthrough: [Phase 2 — Continuous sync](/guides/sync#phase-2-continuous-sync). |
| `removed_from_list` | Items were deleted from the library entirely. `date_from` **won't surface removals** — you can only detect them by diffing. | [`GET /sync/all-items/{type}/{status}`](/api-reference/simkl/get-all-items) with `extended=simkl_ids_only` — cheapest possible payload (just the IDs) — and diff against your local cache. Walkthrough: [Detecting deletions](/guides/sync#phase-2-continuous-sync). |
> Movies don't have `watching` or `hold` — movies are atomic, so those statuses don't apply.
#### Auto-move side effects
When a user **rates** an unrated item, Simkl auto-files it: movies → `Completed`, shows/anime → `Watching`. That auto-move bumps the corresponding list timestamp, so the rated item also appears in subsequent `/sync/all-itemsdate_from` queries.
#### Recommended sync loop
1. On first sync, store every timestamp returned and pull each list once with no `date_from`.
2. Periodically poll this endpoint. If `all` hasn't changed, you're up to date.
3. Otherwise, for each domain whose `all` moved, request only the lists whose per-list timestamp changed using `date_from` = your previously-saved value.
4. Save the new timestamps and repeat.
#### Removal cascade
When `removed_from_list` moves, the user actively deleted items from their library. Refetch with `extended=simkl_ids_only` and diff against your local cache to detect deletions — `date_from` won't surface them. Also clear any local rating you stored for those items: Simkl wipes the rating when an item is removed, which is why removals can move both `removed_from_list` *and* `rated_at`.
Two-phase model (initial pull → activities-checked delta loop), `date_from` semantics, deletion reconciliation, edge cases, and reference implementations in Node and Python.
# Get all items of one type in one status bucket
Source: https://api.simkl.org/api-reference/simkl/get-all-items
/openapi.json get /sync/all-items/{type}/{status}
> ## ⚠️ For continuous sync, do NOT call `/sync/all-items` on a timer
>
> The correct loop, every time you want to check for changes:
>
> 1. **Call [`GET /sync/activities`](/api-reference/simkl/get-activities) first.** It returns a tiny JSON of `last-modified` timestamps per category — costs almost nothing.
> 2. **Compare those timestamps to the ones you saved on your last sync.** If nothing changed, stop here. Do **not** call `/sync/all-items`.
> 3. **Only if a timestamp changed**, call `/sync/all-items?date_from=`. The `date_from` makes the server return only the small delta of items that actually changed, not the user's entire library.
>
> Polling `/sync/all-items` directly on a timer (without checking `/sync/activities` and without `date_from`) downloads the user's whole library every call. It overloads the API server and hurts every other client.
>
> **Apps that do this will have their `client_id` suspended.** No warning, no appeal — we see the traffic pattern and turn the key off.
>
> Read the [**Sync guide**](/guides/sync) end-to-end before shipping anything that calls this endpoint. The full two-phase model (initial full sync → activities-checked delta loop) is documented there with reference implementations in Node and Python.
The two-phase model (initial pull → activities-checked delta loop), `date_from` semantics, deletion reconciliation, edge cases, and reference implementations in Node and Python. **Required reading** before shipping anything that polls this endpoint.
Session lifecycle (`active` / `completed` / `closed`), per-item rewatch fields, episode-level tracking, flag combinations for reading sessions back, and ready-made code for the UI patterns simkl.com uses on every detail page. Required if you set `?allow_rewatch=yes`.
The single endpoint that powers watchlist reads. Both `{type}` and `{status}` are optional path segments, and any combination is valid:
| Path | Returns |
| --- | --- |
| `/sync/all-items` | Every type, every status. The full library. |
| `/sync/all-items/{type}` | A single type (`shows`, `movies`, or `anime`), every status. |
| `/sync/all-items/{type}/{status}` | One type, one status bucket. |
The response shape is the same across all three forms: a top-level object keyed by `shows`, `movies`, and `anime`. Filtered calls just include fewer top-level keys; an empty result returns `{}`. See [Per-endpoint shape matrix](/conventions/null-values#per-endpoint-shape-matrix).
Pair this endpoint with [`GET /sync/activities`](/api-reference/simkl/get-activities) and the `date_from` query parameter for incremental sync — see the [Sync guide](/guides/sync) for the two-phase model.
**Watchlist statuses by type:**
| Type | `watching` | `plantowatch` | `hold` | `dropped` | `completed` |
|------|:-:|:-:|:-:|:-:|:-:|
| `shows` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `anime` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `movies` | — | ✅ | — | ✅ | ✅ |
Movies skip `watching` and `hold` — see [Watchlist statuses](/conventions/list-statuses).
#### Useful query parameters
A quick map of the params below — see each parameter's full schema later on this page.
| Param | What it does |
|---|---|
| `date_from` | Required on every continuous-sync call. Returns only items modified since this ISO-8601 timestamp. |
| `extended=simkl_ids_only` / `=ids_only` / `=full` / `=full_anime_seasons` | Controls response richness — from just `ids.simkl` (smallest) to full metadata + per-episode breakdowns. Pair `=full` and `=full_anime_seasons` with `date_from` — they're significantly larger payloads. |
| `include_all_episodes=yes` / `=original` | Loads canonical `seasons[].episodes[]` for items in `completed` and `dropped` (which skip episode load by default). `yes` synthesizes virtual episode rows where per-episode data is missing; `original` returns real rows only. |
| `episode_watched_at=yes` | Adds per-episode `watched_at` timestamps to every episode that's been loaded. Modifier — episodes must already be loaded by `extended=full` or `include_all_episodes=yes`. |
| `episode_tvdb_id=yes` | Adds `ids.tvdb_id` per episode. |
| `next_watch_info=yes` | On `watching` items with a next episode, attaches `next_to_watch_info` (`title`, `season`, `episode`, `date`). |
| `memos=yes` | Includes the user's per-item `memo` object (`text` capped at 140 chars). |
| `anime_type` | Filter anime entries by anime type (`tv`, `movie`, `ova`, `ona`, `special`, `music video`). |
| `language=en` | Force English titles instead of the user's profile language. |
| `allow_rewatch=yes` | Synthesize one extra entry per rewatch session alongside the canonical row. **Simkl Pro / VIP only.** See the [Rewatches guide](/guides/rewatches). |
**Rewatches** (Simkl Pro / VIP). Without the flag, each item — movie, show, or anime — appears once in the response, reflecting the user's current watch state. Set `?allow_rewatch=yes` and any item with saved rewatch sessions appears multiple times: the normal entry, plus one extra entry per rewatch session. The extra entries carry `is_rewatch: true`, `rewatch_id`, `rewatch_status` (`active` / `completed` / `closed`), `last_watched_at`, and `watched_episodes_count`, so you can tell them apart from the main entry and from each other.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Anime details
Source: https://api.simkl.org/api-reference/simkl/get-anime
/openapi.json get /anime/{id}
Full detail record for one anime — title, overview, year, runtime, network, status, genres, studios (list of `{id, name}`), related titles, ratings, posters, fanart, external IDs, alternate titles, trailers, episode count, AniDB-mapped TVDB seasons, user recommendations. The default response is already complete; no flags needed.
Responses are **Cloudflare-cached by Simkl ID**, so repeat lookups of popular titles are near-free. Parallel requests against this endpoint are explicitly allowed (see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed)).
**Cache invalidation is automatic.** When Simkl updates the underlying record (admin edits, automated metadata refresh, image swap, related-titles change, etc.), the corresponding Cloudflare cache entry is purged server-side. The next call to this endpoint returns the fresh data — there's no TTL to wait out and no client-side cache-busting needed. Your own app-level cache, if any, still has to be invalidated by your client.
Use a Simkl ID for the lookup. If you only have an external ID, resolve it via [`GET /redirect`](/api-reference/redirect) first.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Anime airing today, tomorrow, or on a specific date
Source: https://api.simkl.org/api-reference/simkl/get-anime-airing
/openapi.json get /anime/airing
Currently airing anime — same data that powers the [Simkl anime calendar](https://simkl.com/anime/airing/). No `access_token` required.
Same shape and parameters as [`/tv/airing`](/api-reference/simkl/get-tv-airing) but for the anime catalog: each item includes an `anime_type` field (`tv`, `ona`, `ova`, `movie`, `special`, `music`), and the `episode` block omits `season` (anime numbering is single-season — AniDB sequential).
**Prefer the cached calendar endpoints for high-traffic use cases.** `/anime/airing` is **uncached** — every request hits the origin. For widgets, mobile-app home screens, or anything that fetches this on app launch / wake / timer, use the CDN-cached [Calendar data files](/api-reference/calendar) on `data.simkl.in` — `/calendar/anime.json` (rolling window: yesterday + next 33 days) or `/calendar/{year}/{month}/anime.json` (monthly archive) serve the same per-day airing data, edge-cached so most requests don't even reach origin. Reserve `/anime/airing` for ad-hoc queries by a specific date that the calendar files don't pre-bake.
Both forms still need the standard URL params on every request — `client_id`, `app-name`, `app-version` (and the `User-Agent` header) — same as every other Simkl endpoint. See [Headers and required parameters](/conventions/headers).
#### Query parameters
| Param | Default | Notes |
|---|---|---|
| `date` | `today` | `today`, `tomorrow`, or `DD-MM-YYYY`. Bogus values silently fall back to `today`. |
| `sort` | `time` | `time`, `rank`, `popularity`. Bogus values silently fall back to `time`. |
#### Item shape
```json
{
"title": "string",
"year": "integer | null",
"date": "ISO-8601 string with -05:00 offset | null",
"poster": "string (relative path; prepend https://simkl.in/posters/ + size)",
"rank": "integer | null",
"url": "string (relative simkl.com URL)",
"ids": {
"simkl_id": "integer",
"slug": "string"
},
"episode": {
"episode": "integer",
"url": "string"
},
"anime_type": "tv | ona | ova | movie | special | music"
}
```
#### Nulls — what they mean
| Field | When null | Type |
|---|---|---|
| `date` | Catalog has no `Airs_Time` on file for this episode (older / low-data titles) | [Type 4](/conventions/null-values#type-4) |
| `rank` | Item not yet ranked, or rank value >= 999999 sentinel | [Type 4](/conventions/null-values#type-4) |
| `episode.season` | Always omitted on anime — single-season AniDB numbering. | [Type 2](/conventions/null-values#type-2) |
#### Error responses
| Status | When |
|---|---|
| `412 client_id_failed` | Missing or invalid `client_id` |
| `500` | Server error |
No `400` — invalid `date`/`sort` values silently fall back to defaults. No `404` — empty result is `[]` with status `200`.
> For broader catalog browsing, see [Anime by genre](/api-reference/simkl/get-anime-genres).
# List episodes for an anime
Source: https://api.simkl.org/api-reference/simkl/get-anime-episodes
/openapi.json get /anime/episodes/{id}
Returns the full episode list for a Simkl anime ID, including specials. For anime, season is omitted in regular episodes (anime is treated as a single canonical season per AniDB); specials use `type: "special"` and lack a `season`/`episode` pair.
#### Item shape
```json
{
"title": "To You, in 2000 Years",
"description": "...",
"episode": 1,
"type": "episode",
"aired": true,
"img": "https://simkl.in/episodes/...",
"date": "2013-04-07T00:00:00+09:00",
"ids": {
"simkl_id": 1010234
},
"tvdb": {
"season": 1,
"episode": 1
}
}
```
`tvdb.season` / `tvdb.episode` reflect the original TVDB numbering when AniDB mapping diverges.
Responses are **Cloudflare-cached by Simkl ID**, so repeat lookups of popular anime are near-free. Parallel requests against this endpoint are explicitly allowed (see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed)).
**Cache invalidation is automatic.** When Simkl updates the underlying episode list (new episode airs, airdate change, title edit, image swap, etc.), the corresponding Cloudflare cache entry is purged server-side. The next call returns the fresh data — there's no TTL to wait out. Your own app-level cache, if any, still has to be invalidated by your client.
Use the parent anime's Simkl ID. If you only have an external ID (MAL, AniDB, AniList, Kitsu, …), resolve it via [`GET /redirect`](/api-reference/redirect) first.
Errors: `400 empty_id` if `id` is missing.
# Anime by genre
Source: https://api.simkl.org/api-reference/simkl/get-anime-genres
/openapi.json get /anime/genres/{genre}/{type}/{network}/{year}/{sort}
**Not for TV / console / 10-foot apps.** The V1 genre-browse endpoints return a thin per-item shape (`title`, `year`, `poster`, `ids`, `ratings`, `rank`). Building a TV-app grid that shows overview text, full ratings, networks, runtimes, recommendations, or trailers would force one per-item refetch against the detail endpoint per visible card. **Wait for the V2 Beta API**, which returns the richer per-item shape TV-app surfaces need in a single call. If you're targeting TV / console / streaming-box clients, please hold off integrating these endpoints.
Browse anime filtered by genre, type, network, year, and sort order. Path is `/anime/genres/{genre}/{type}/{network}/{year}/{sort}` — all segments **required** (use `all` as the wildcard).
This is the **5-segment** variant (no `country` segment — anime is dominated by Japanese productions, country filtering isn't useful here).
| Path param | Values |
|---|---|
| `genre` | `all`, `action`, `adventure`, `comedy`, `drama`, `ecchi`, `educational`, `fantasy`, `gag-humor`, `gore`, `harem`, `historical`, `horror`, `idol`, `isekai`, `josei`, `kids`, `magic`, `martial-arts`, `mecha`, `military`, `music`, `mystery`, `mythology`, `parody`, `psychological`, `racing`, `reincarnation`, `romance`, `samurai`, `school`, `sci-fi`, `seinen`, `shoujo`, `shoujo-ai`, `shounen`, `shounen-ai`, `slice-of-life`, `space`, `sports`, `strategy-game`, `super-power`, `supernatural`, `thriller`, `vampire`, `yaoi`, `yuri` |
| `type` | `all`, `tv`, `movies`, `ovas`, `onas`, `specials`, `music` |
| `network` | `all` or a network slug (`tv-tokyo`, `crunchyroll`, …) |
| `year` | `all`, single year, or decade |
| `sort` | `popular-this-week`, `popular-this-month`, `popular-all-time`, `rank`, `release-date`, `voted`, `watched` |
Items carry an additional `anime_type` field (`tv` / `movie` / `ova` / `ona` / `special` / `music`).
#### Pagination
| Param | Default | Notes |
|---|---|---|
| `page` | `1` | Hard-capped server-side at `20`. Higher values clamp silently. |
| `limit` | `60` | Hard-capped server-side at `60`. Higher values clamp silently. Returned `X-Pagination-Limit` reflects the clamped value. |
`X-Pagination-*` headers on every response — see [Pagination](/api-reference/pagination).
#### Silent fallbacks
Bad path segments DO NOT return errors:
| Bad input | What happens |
|---|---|
| Unknown `genre` slug (`zzz`) | Top-level response is `null` (NOT `[]`). |
| Unknown `year` (e.g. `zzz`) | Silently treated as `all` — full result set. |
| Unknown `sort` (`zzzsortzzz`) | Silently treated as default sort order. |
| Unknown `country` / `network` | Silently treated as `all`. |
#### Errors
| Status | When |
|---|---|
| `412 client_id_failed` | Missing or invalid `client_id` |
| `500` | Server error |
No `400` or `404` — bad segments fall back silently or return `null`.
# Anime premieres (new + upcoming)
Source: https://api.simkl.org/api-reference/simkl/get-anime-premieres
/openapi.json get /anime/premieres/{param}
Anime premieres — recently aired or upcoming new anime. Mirrors the [Simkl Anime Premieres](https://simkl.com/anime/premieres/) page. No `access_token` required.
Pass `new` for anime that already aired (newest first), or `soon` for anime airing in the next few weeks (soonest first). Any path value other than `new` is treated as `soon`.
The two shapes differ slightly: items in the `new` response include `rank` and `ratings`; items in the `soon` response don't carry those fields at all (the title hasn't aired enough to be ranked or rated yet). Every item carries an `anime_type` field (`tv` / `ona` / `ova` / `movie` / `special` / `music`).
Same behavior as [`/tv/premieres`](/api-reference/simkl/get-tv-premieres) but **without the US/CA filter** — the anime catalog is served globally. Dates use a `+09:00` offset (Japan time).
#### Query parameters
| Param | Default | Notes |
|---|---|---|
| `type` | (any) | Optional. `all`, `tv`, `movies`, `ovas`, `onas`, or `music`. Anything else is ignored. |
| `page` | `1` | 1 to 20. Higher values are reduced to 20. |
| `limit` | `60` | 1 to 60. Higher values are reduced to 60. |
`X-Pagination-*` headers on every response — see [Pagination](/api-reference/pagination).
The full per-item shape is in the **Response** panel on the right.
#### Errors
| Status | When |
|---|---|
| `412` | Missing or invalid `client_id` |
| `500` | Server error |
Bogus `param` or `type` values silently fall back — no `400`. The endpoint never returns `404`.
# Top-rated anime
Source: https://api.simkl.org/api-reference/simkl/get-best-anime
/openapi.json get /anime/best/{filter}
Top-rated anime. Mirrors the [Simkl Best Anime](https://simkl.com/anime/best-anime/) pages. No `access_token` required.
Same behavior as [`/tv/best/{filter}`](/api-reference/simkl/get-best-tv) with one difference: items carry `ratings.mal` instead of `ratings.imdb`. Items do **not** carry `anime_type` here — for format-aware browsing use [`GET /anime/genres/...`](/api-reference/simkl/get-anime-genres).
Pick a bucket via the `{filter}` path segment:
| Filter | What you get |
|---|---|
| `all` | All-time top-rated. |
| `year` | Top-rated for the current year. |
| `month` | Top-rated for the current month. |
| `voted` | Most-voted titles (sorted by total MAL votes). Items also include a `votes` count. |
| `watched` | Most-watched this month. Items also include a `watched` count. |
Unknown filter values fall back to `all`.
Optionally narrow by `type=all`, `tv`, `movies`, `ovas`, `onas`, or `music`. Unknown values are ignored.
**60 items, no pagination.** The endpoint always returns up to 60 items in one call. The `page` and `limit` query parameters are accepted but ignored. For paginated browsing use [`GET /anime/genres/...`](/api-reference/simkl/get-anime-genres).
#### Errors
| Status | When |
|---|---|
| `412` | Missing or invalid `client_id` |
| `500` | Server error |
Unknown `filter` or `type` values silently fall back — no `400`. The endpoint never returns `404`.
# Top-rated TV shows
Source: https://api.simkl.org/api-reference/simkl/get-best-tv
/openapi.json get /tv/best/{filter}
Top-rated TV shows. Mirrors the [Simkl Best TV](https://simkl.com/tv/best-shows/) pages. No `access_token` required.
Pick a bucket via the `{filter}` path segment:
| Filter | What you get |
|---|---|
| `all` | All-time top-rated. |
| `year` | Top-rated for the current year. |
| `month` | Top-rated for the current month. |
| `voted` | Most-voted titles (sorted by total IMDB votes). Items also include a `votes` count. |
| `watched` | Most-watched this month. Items also include a `watched` count. |
Unknown filter values fall back to `all`.
Optionally narrow by `type=series`, `documentary`, `entertainment`, or `animation`. Unknown values are ignored.
**60 items, no pagination.** The endpoint always returns up to 60 items in one call. The `page` and `limit` query parameters are accepted but ignored. For paginated browsing use [`GET /tv/genres/...`](/api-reference/simkl/get-tv-genres).
**`type=documentary` can return `null`.** When the type filter doesn't match anything in the top set, the response body is bare `null` rather than an empty array. Handle both shapes in your parser.
#### Errors
| Status | When |
|---|---|
| `412` | Missing or invalid `client_id` |
| `500` | Server error |
Unknown `filter` or `type` values silently fall back — no `400`. The endpoint never returns `404`.
# Recently changed catalog items
Source: https://api.simkl.org/api-reference/simkl/get-changes
/openapi.json get /changes
Returns Simkl catalog IDs whose metadata changed in the last **N** days, grouped by type. Use it to keep the items already on a user's watchlist fresh — when a show airs a new episode, an upcoming title starts airing, or a movie's metadata is updated, the corresponding ID appears here.
**If all you need is "which episodes are airing soon?"** — use the [Calendar data files](/api-reference/calendar) on `data.simkl.in` instead. They're CDN-cached and give you every upcoming episode in a single fast call:
- **Rolling window** (`/calendar/{type}.json`) — yesterday + the next ~33 days. The default for "what's on now and next".
- **Monthly archive** (`/calendar/{year}/{month}/{type}.json`) — fetch previous, current, and next month separately when you need a wider calendar grid view (e.g. a 3-month strip).
Reserve `/changes` for the wider job of tracking catalog metadata updates (status flips, ratings, posters, runtimes) on items already on the user's watchlist.
#### How tracking apps use it
You already know which items the user is tracking from the [Sync guide](/guides/sync) loop ([`GET /sync/activities`](/api-reference/simkl/get-activities) + [`GET /sync/all-items`](/api-reference/simkl/get-all-items) with `date_from=`). That tells you when the **user** touched their lists. `/changes` is the complementary call — it tells you when **Simkl's catalog metadata** for any item moved, independent of whether the user touched their list. New episodes that just aired, a show whose status flipped from *upcoming* to *airing*, a movie whose runtime / poster / overview was updated.
#### When to actually call it
Treat it like Sync: **trigger on a user-visible event, never on a background timer.** The intended cadence is **at most once per day per user**, gated on a stored timestamp:
| Trigger | What to do |
|---|---|
| App launch / wake-from-background | If `now() − last_changes_poll ≥ 24 h`, run the loop below and save `now()` as `last_changes_poll`. If less than 24 h, skip — the 14-day response window means the same IDs will still be there tomorrow. |
| Manual refresh button | Always allow — bypasses the 24 h gate so the user can force a check. |
| Never | Background `setInterval` timers, per-user crons, real-time loops, polling on every screen transition. These will get the `client_id` rate-limited. |
#### The loop (when the trigger fires)
1. Call `/changes?date_from=`. Narrow with `type=` to only the catalogs the user has items in (skip `anime` if the user has no anime, etc.).
2. **Intersect in your client:** `{IDs the response returned} ∩ {IDs the user has on any watchlist}`. You already have the user's watchlist locally from the Sync loop — this is a Set lookup, ~microseconds.
3. **Apply the skip rules below** to the intersection. Most items get dropped here — only the ones where new metadata is plausible survive.
4. For each surviving ID, refetch the matching cached endpoint:
| Refetch for | Endpoint |
|---|---|
| Movie metadata (poster, overview, ratings, release date) | [`GET /movies/{id}`](/api-reference/simkl/get-movie) |
| TV show metadata + new episode counts / status | [`GET /tv/{id}`](/api-reference/simkl/get-tv-show) and/or [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) |
| Anime metadata + new episodes | [`GET /anime/{id}`](/api-reference/simkl/get-anime) and/or [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) |
The detail endpoints are edge-cached by Simkl ID, so popular titles come straight from Cloudflare without hitting origin. **When an item's metadata or episodes change, Simkl automatically purges its Cloudflare cache entry** — so a refetch right after `/changes` flagged the ID is guaranteed to return the fresh data, never a stale edge copy. After the refetch, save `now()` as `last_changes_poll`.
#### Skip rules — when not to refetch
**First, restrict the intersect to active lists.** Items on the user's `completed` and `dropped` lists are titles they're done with — there's no UX win in refreshing metadata for a show they're never opening again. Intersect `/changes` only against items on `watching`, `plantowatch`, and `hold` (plus their anime equivalents); drop the rest before you even consider a refetch.
After that filter, the remaining items still benefit from status-based throttling. Cache the last-known `status` (TV/anime) or release state (movies) for each item in the user's watchlist, and track the last time you refetched each item. Use the rules below — most items will sit in a state where new metadata is implausible.
| Last-known state | When to actually refetch |
|---|---|
| TV / anime, `status: airing` | Every time the ID appears in `/changes` — new episodes can drop any week. |
| TV / anime, `status: tba` (upcoming) | Weekly — what matters is the moment the status flips to `airing` and the first real air date locks in. |
| TV / anime, `status: ended`, ended **less than 30 days ago** | Every time the ID appears — late corrections to ratings / episode counts happen here. |
| TV / anime, `status: ended`, ended **more than 30 days ago** | Skip. Refetch once a month at most. Metadata on a finished show is effectively frozen. |
| Movie, released **less than 6 months ago** | Every time the ID appears — ratings / poster art / overview tend to churn early. |
| Movie, released **more than 6 months ago** | Skip. Refetch quarterly at most. |
| Movie, unreleased / `tba` | Weekly — you mostly care about the release-date update. |
The intersect + skip combination typically reduces a `/changes` response of tens of thousands of IDs down to a handful of detail-endpoint refetches per day per user — even for power users with very large libraries.
#### Query parameters
| Param | Default | Notes |
|---|---|---|
| `date_from` | 14 days ago | ISO date (e.g. `YYYY-MM-DD`). The server **clamps** to no older than 14 days ago — older values silently snap to the cap. Invalid values (e.g. `BOGUS`) silently fall back to the default. Future dates return `{}`. |
| `type` | `anime,shows,movies` | CSV. Any combination of `anime`, `shows`, `movies`. Unknown values silently bucket as anime — keep the values in the listed set. Use it to skip catalogs the user doesn't have anything on. |
#### Response shape
An object with up to three keys (`movies`, `shows`, `anime`), each an array of integer Simkl IDs. **Keys are omitted when their bucket is empty.** If nothing changed in the window, the response is `{}` (an empty object, NOT `[]`).
```json
{
"movies": [
56145,
1029384
],
"shows": [
17465,
92834
],
"anime": [
39687
]
}
```
IDs are returned in no particular order. Items modified in the **last 5 minutes are excluded** so partially-written records don't leak into the delta. **Each response contains at most 50,000 IDs** — if the catalog produces more (rare; only on very wide windows across all three types), narrow the call with `type=` to fit under the cap.
# Movie details
Source: https://api.simkl.org/api-reference/simkl/get-movie
/openapi.json get /movies/{id}
Full detail record for one movie — title, overview, year, runtime, country, language, certification, genres, director, ratings, posters, fanart, external IDs, alternate titles, release-date list per region, budget, revenue, trailers, similar-movie recommendations. The default response is already complete; no flags needed.
Responses are **Cloudflare-cached by Simkl ID**, so repeat lookups of popular titles are near-free. Parallel requests against this endpoint are explicitly allowed (see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed)).
**Cache invalidation is automatic.** When Simkl updates the underlying record (admin edits, automated metadata refresh, image swap, related-titles change, etc.), the corresponding Cloudflare cache entry is purged server-side. The next call to this endpoint returns the fresh data — there's no TTL to wait out and no client-side cache-busting needed. Your own app-level cache, if any, still has to be invalidated by your client.
Use a Simkl ID for the lookup. If you only have an external ID, resolve it via [`GET /redirect`](/api-reference/redirect) first.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Movies by genre
Source: https://api.simkl.org/api-reference/simkl/get-movies-genres
/openapi.json get /movies/genres/{genre}/{type}/{country}/{year}/{sort}
**Not for TV / console / 10-foot apps.** The V1 genre-browse endpoints return a thin per-item shape (`title`, `year`, `poster`, `ids`, `ratings`, `rank`). Building a TV-app grid that shows overview text, full ratings, networks, runtimes, recommendations, or trailers would force one per-item refetch against the detail endpoint per visible card. **Wait for the V2 Beta API**, which returns the richer per-item shape TV-app surfaces need in a single call. If you're targeting TV / console / streaming-box clients, please hold off integrating these endpoints.
Browse movies filtered by genre, country, year, and sort order. Path is `/movies/genres/{genre}/{type}/{country}/{year}/{sort}` — all segments **required** (use `all` as the wildcard).
The `type` segment is reserved — always pass the literal value `movies`.
| Path param | Values |
|---|---|
| `genre` | `all`, `action`, `adventure`, `animation`, `comedy`, `crime`, `documentary`, `drama`, `erotica`, `family`, `fantasy`, `history`, `horror`, `music`, `mystery`, `romance`, `science-fiction`, `thriller`, `tv-movie`, `war`, `western` |
| `type` | `movies` (literal) |
| `country` | `all` or ISO 3166-1 alpha-2 (`us`, `gb`, `jp`, …) |
| `year` | `all`, single year (`2019`), or decade (`2010s`, `2000s`) |
| `sort` | `popular-this-week`, `popular-this-month`, `popular-all-time`, `rank`, `release-date`, `voted`, `watched` |
Items always carry `ids.tmdb` — the discover query filters out movies without a TMDB-linked record.
#### Pagination
| Param | Default | Notes |
|---|---|---|
| `page` | `1` | Hard-capped server-side at `20`. Higher values clamp silently. |
| `limit` | `60` | Hard-capped server-side at `60`. Higher values clamp silently. Returned `X-Pagination-Limit` reflects the clamped value. |
`X-Pagination-*` headers on every response — see [Pagination](/api-reference/pagination).
#### Silent fallbacks
Bad path segments DO NOT return errors:
| Bad input | What happens |
|---|---|
| Unknown `genre` slug (`zzz`) | Top-level response is `null` (NOT `[]`). |
| Unknown `year` (e.g. `zzz`) | Silently treated as `all` — full result set. |
| Unknown `sort` (`zzzsortzzz`) | Silently treated as default sort order. |
| Unknown `country` / `network` | Silently treated as `all`. |
#### Errors
| Status | When |
|---|---|
| `412 client_id_failed` | Missing or invalid `client_id` |
| `500` | Server error |
No `400` or `404` — bad segments fall back silently or return `null`.
# Request a PIN code
Source: https://api.simkl.org/api-reference/simkl/get-pin
/openapi.json get /oauth/pin
Step 1 of the **PIN flow** (also called the device flow). Best for TVs, consoles, smart watches, CLI tools — anywhere typing a URL is hard. You don't need `client_secret` for this flow.
The response contains a 5-character `user_code` to display, a `verification_uri` for the user to visit, an `expires_in` lifetime (15 minutes), and an `interval` you must respect when polling (5 seconds).
#### Parameters
| Param | Required | Notes |
|---|---|---|
| `client_id` | yes | Sent as `?client_id=…` URL query parameter. The `simkl-api-key` header is also accepted but the URL-parameter form is preferred. |
| `redirect` | no | URL the simkl.com/pin page sends the user to **after they approve**. Must match a URL pre-registered in your app's developer settings. Mostly relevant for browser-extension and web-flavoured PIN integrations. |
#### About the `device_code` response field
The response includes a `device_code` field whose value is the literal string `"DEVICE_CODE"` — it's a placeholder kept for compatibility with the OAuth 2.0 Device Authorization Grant response shape. Clients only need `user_code` (what you display, and what you poll on). You can ignore `device_code` entirely.
**Not the RFC 8628 device flow.** Simkl's PIN flow is *conceptually* similar to [RFC 8628 (OAuth Device Authorization Grant)](https://datatracker.ietf.org/doc/html/rfc8628) but the wire format differs in several spots:
- `device_code` is a hardcoded placeholder, not a real opaque token.
- Polling happens at `GET /oauth/pin/{user_code}` instead of `POST /oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`.
- Pending poll responses are `{"result": "KO", "message": "Authorization pending"}` instead of `400 + {"error": "authorization_pending"}`.
Generic device-flow libraries (e.g. `openid-client` device-flow extension) won't work out of the box. Either write a custom client for the wire format above, or follow the [PIN flow walkthrough](/api-reference/pin) which uses the documented endpoints directly.
Device authorization for TVs, consoles, smart watches, and CLI tools — show a 5-character code, the user enters it at simkl.com/pin, the app polls for the access token.
# Get paused playback sessions for one type
Source: https://api.simkl.org/api-reference/simkl/get-playback-sessions
/openapi.json get /sync/playback/{type}
Returns the user's saved paused playbacks — created by `/scrobble/pause` or `/scrobble/stop` with progress < 80%. The `{type}` segment is optional:
| Path | Returns |
|---|---|
| `GET /sync/playback` | All paused playbacks (episodes + movies). |
| `GET /sync/playback/episodes` | TV/anime episode playbacks only. |
| `GET /sync/playback/movies` | Movie playbacks only. |
The response shape is identical across the three forms; only the included items differ. Resume a session by calling [`/scrobble/start`](/api-reference/simkl/scrobble-start) with the same item.
#### Query parameters
| Param | Effect | Default |
|---|---|---|
| `date_from` | Only sessions with `paused_at >= date_from`. | — |
| `date_to` | Only sessions with `paused_at < date_to`. | — |
| `hide_watched` | Exclude items already watched after the pause was created. | `true` |
| `limit` | Max items returned (1–10000). | `10000` |
#### Item shape
```json
{
"id": 12345,
"progress": 42.2,
"paused_at": "2024-04-30T22:13:00Z",
"type": "episode",
"episode": {
"season": 1,
"number": 3,
"title": "Chapter Three: Holly Jolly",
"tvdb_season": 1,
"tvdb_number": 3
},
"show": {
"title": "Stranger Things",
"year": 2016,
"ids": {
"simkl": 39687,
"imdb": "tt4574334",
"tvdb": 305288
}
}
}
```
> Note: `progress` is a **percentage (0-100)** — same scale as the scrobble endpoints. The example values shown above (`75`, `45.5`, `42.2`) are real outputs from the API.
Members can browse and clean these up at [simkl.com/my/history/playback-progress-manager](https://simkl.com/my/history/playback-progress-manager/).
#### Retention by plan
- **Free** — 7 days · **PRO** — 30 days · **VIP** — 90 days
Sessions persist until they're manually removed via [`DELETE /sync/playback/{id}`](/api-reference/simkl/delete-playback), replaced by the next scrobble update on the same title, or aged out per the plan retention window above. They are not auto-deleted on `paused_at` expiry — you'll see the same session in the response indefinitely until one of those three things happens.
Real-time playback tracking — `/start`, `/pause`, `/stop` lifecycle, paused-playback resumption across devices, when scrobble auto-completes, and the difference between `/scrobble/checkin` (fire-and-forget) and `/scrobble/start` (active tracking).
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Redirect to a user's last-watched cover image
Source: https://api.simkl.org/api-reference/simkl/get-recently-watched-image
/openapi.json get /users/recently-watched-background/{user_id}
Pulls metadata about the user's most recently watched item — useful for "Now Watching" widgets, dashboard backgrounds, and embedded user-cards.
PUBLIC endpoint — no `access_token` required, just your `client_id`. The target user's profile must be public; private profiles return `404`.
#### Two response modes (selected by the `image` query param)
| `image` | Status | Body | Use case |
|---|---|---|---|
| _omitted_ | `200` | JSON with `id`, `url`, `title`, `poster`, `fanart` | Server-side render or custom card layout |
| `poster` | `302` | Empty; `Location: https://simkl.net/posters/_0.jpg` | Drop the request URL straight into `` |
| `fanart` | `302` | Empty; `Location: https://simkl.net/fanart/_0.jpg` | Same — full-bleed background image |
#### Drop-in `` example
The `?image=` redirect modes are designed for `` tags. The browser follows the 302 automatically and renders the JPG — no JSON parsing, no string concatenation, no extra requests:
```html
```
#### JSON mode shape
The no-param mode returns the same image keys as the redirect modes — concatenate manually if you want to skip the second round-trip:
```json
{
"id": 17465,
"url": "https://simkl.com/tv/17465/game-of-thrones",
"title": "Game of Thrones",
"poster": "17/17465posterkey",
"fanart": "17/17465fanartkey"
}
```
Render as `https://simkl.net/posters/_0.jpg` / `https://simkl.net/fanart/_0.jpg`.
# TV shows airing today, tomorrow, or on a specific date
Source: https://api.simkl.org/api-reference/simkl/get-tv-airing
/openapi.json get /tv/airing
Currently airing TV shows — same data that powers the [Simkl TV calendar](https://simkl.com/tv/airing/). No `access_token` required.
Same shape and parameters as [`/anime/airing`](/api-reference/simkl/get-anime-airing) but for the TV catalog: no `anime_type` field, and the per-item `episode` block includes a `season` integer.
**Prefer the cached calendar endpoints for high-traffic use cases.** `/tv/airing` is **uncached** — every request hits the origin. For widgets, mobile-app home screens, or anything that fetches this on app launch / wake / timer, use the CDN-cached [Calendar data files](/api-reference/calendar) on `data.simkl.in` instead — both the rolling-window `/calendar/{type}.json` (yesterday + next 33 days) and the monthly archive `/calendar/{year}/{month}/{type}.json` serve the same per-day airing data, edge-cached so most requests don't even reach origin. Reserve `/tv/airing` for ad-hoc queries by a specific date that the calendar files don't pre-bake.
Both forms still need the standard URL params on every request — `client_id`, `app-name`, `app-version` (and the `User-Agent` header) — same as every other Simkl endpoint. See [Headers and required parameters](/conventions/headers).
#### Query parameters
| Param | Default | Notes |
|---|---|---|
| `date` | `today` | `today`, `tomorrow`, or `DD-MM-YYYY`. Bogus values silently fall back to `today`. |
| `sort` | `time` | `time`, `rank`, `popularity`. Bogus values silently fall back to `time`. |
#### Item shape
```json
{
"title": "string",
"year": "integer | null (extracted from the episode air time)",
"date": "ISO-8601 string with -05:00 offset | null",
"poster": "string (relative path; prepend https://simkl.in/posters/ + size)",
"rank": "integer | null (Simkl popularity rank; null when not yet ranked)",
"url": "string (relative simkl.com URL)",
"ids": {
"simkl_id": "integer",
"slug": "string"
},
"episode": {
"season": "integer",
"episode": "integer",
"url": "string"
}
}
```
#### Nulls — what they mean
| Field | When null | Type |
|---|---|---|
| `date` | Catalog has no `Airs_Time` on file for this episode (rare — usually older or low-data titles) | [Type 4](/conventions/null-values#type-4) |
| `rank` | Item not yet ranked, or rank value >= 999999 sentinel | [Type 4](/conventions/null-values#type-4) |
#### Error responses
| Status | When |
|---|---|
| `412 client_id_failed` | Missing or invalid `client_id` |
| `500` | Server error |
No `400` — invalid `date`/`sort` values silently fall back to defaults. No `404` — empty result is `[]` with status `200`.
# List episodes for a TV show
Source: https://api.simkl.org/api-reference/simkl/get-tv-episodes
/openapi.json get /tv/episodes/{id}
Returns the full episode list for a Simkl TV show ID. Items include `season`, `episode`, `title`, `description`, `aired` (boolean), `img`, `date` (timezone-shifted), and `ids.simkl_id`. Specials appear with `type: "special"` after the regular episodes.
Responses are **Cloudflare-cached by Simkl ID**, so repeat lookups of popular shows are near-free. Parallel requests against this endpoint are explicitly allowed (see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed)).
**Cache invalidation is automatic.** When Simkl updates the underlying episode list (new episode airs, airdate change, title edit, image swap, etc.), the corresponding Cloudflare cache entry is purged server-side. The next call returns the fresh data — there's no TTL to wait out. Your own app-level cache, if any, still has to be invalidated by your client.
Use the parent show's Simkl ID. If you only have an external ID, resolve it via [`GET /redirect`](/api-reference/redirect) first.
# TV by genre
Source: https://api.simkl.org/api-reference/simkl/get-tv-genres
/openapi.json get /tv/genres/{genre}/{type}/{country}/{network}/{year}/{sort}
**Not for TV / console / 10-foot apps.** The V1 genre-browse endpoints return a thin per-item shape (`title`, `year`, `poster`, `ids`, `ratings`, `rank`). Building a TV-app grid that shows overview text, full ratings, networks, runtimes, recommendations, or trailers would force one per-item refetch against the detail endpoint per visible card. **Wait for the V2 Beta API**, which returns the richer per-item shape TV-app surfaces need in a single call. If you're targeting TV / console / streaming-box clients, please hold off integrating these endpoints.
Browse TV shows filtered by genre, type, country, network, year, and sort order. Path is `/tv/genres/{genre}/{type}/{country}/{network}/{year}/{sort}` — all segments **required** (use `all` as the wildcard).
This is the **6-segment** variant (one more than movies + anime — TV has both `country` AND `network` filters).
| Path param | Values |
|---|---|
| `genre` | `all`, `action`, `adventure`, `animation`, `awards-show`, `children`, `comedy`, `crime`, `documentary`, `drama`, `erotica`, `family`, `fantasy`, `food`, `game-show`, `history`, `home-and-garden`, `horror`, `indie`, `korean-drama`, `martial-arts`, `mini-series`, `musical`, `mystery`, `news`, `podcast`, `reality`, `romance`, `science-fiction`, `soap`, `special-interest`, `sport`, `suspense`, `talk-show`, `thriller`, `travel`, `video-game-play`, `war`, `western` |
| `type` | `all`, `series`, `mini-series`, `specials` |
| `country` | `all` or ISO 3166-1 alpha-2 |
| `network` | `all` or a network slug (`hbo`, `netflix`, `apple-tv`, `prime-video`, …) |
| `year` | `all`, single year, or decade |
| `sort` | `popular-this-week`, `popular-this-month`, `popular-all-time`, `rank`, `release-date`, `voted`, `watched` |
#### Pagination
| Param | Default | Notes |
|---|---|---|
| `page` | `1` | Hard-capped server-side at `20`. Higher values clamp silently. |
| `limit` | `60` | Hard-capped server-side at `60`. Higher values clamp silently. Returned `X-Pagination-Limit` reflects the clamped value. |
`X-Pagination-*` headers on every response — see [Pagination](/api-reference/pagination).
#### Silent fallbacks
Bad path segments DO NOT return errors:
| Bad input | What happens |
|---|---|
| Unknown `genre` slug (`zzz`) | Top-level response is `null` (NOT `[]`). |
| Unknown `year` (e.g. `zzz`) | Silently treated as `all` — full result set. |
| Unknown `sort` (`zzzsortzzz`) | Silently treated as default sort order. |
| Unknown `country` / `network` | Silently treated as `all`. |
#### Errors
| Status | When |
|---|---|
| `412 client_id_failed` | Missing or invalid `client_id` |
| `500` | Server error |
No `400` or `404` — bad segments fall back silently or return `null`.
# TV premieres (new + upcoming)
Source: https://api.simkl.org/api-reference/simkl/get-tv-premieres
/openapi.json get /tv/premieres/{param}
TV premieres — recently aired or upcoming new shows. Mirrors the [Simkl TV Premieres](https://simkl.com/tv/premieres/) page. No `access_token` required.
Pass `new` for shows that already premiered (newest first), or `soon` for shows premiering in the next few weeks (soonest first). Any path value other than `new` is treated as `soon`.
The two shapes differ slightly: items in the `new` response include `rank` and `ratings`; items in the `soon` response don't carry those fields at all (the show hasn't aired enough to be ranked or rated yet).
**US and Canada only.** The list is restricted to shows produced in the US or Canada — there's no opt-out. If you want premieres from other regions, use [`GET /tv/genres/{genre}/{type}/{country}/{network}/{year}/{sort}`](/api-reference/simkl/get-tv-genres) with the country segment set to your target (`kr`, `jp`, `gb`, etc.).
#### Query parameters
| Param | Default | Notes |
|---|---|---|
| `type` | (any) | Optional. `series` or `documentary`. Anything else is ignored and you get the full list. |
| `page` | `1` | 1 to 20. Higher values are reduced to 20. |
| `limit` | `60` | 1 to 60. Higher values are reduced to 60. |
`X-Pagination-*` headers on every response — see [Pagination](/api-reference/pagination).
The full per-item shape is in the **Response** panel on the right.
#### Errors
| Status | When |
|---|---|
| `412` | Missing or invalid `client_id` |
| `500` | Server error |
Bogus `param` or `type` values silently fall back — no `400`. The endpoint never returns `404`.
# TV show details
Source: https://api.simkl.org/api-reference/simkl/get-tv-show
/openapi.json get /tv/{id}
Full detail record for one TV show — title, overview, year, runtime, country, certification, network, genres, status, first/last-aired dates, total episodes, airs schedule, ratings, posters, fanart, external IDs, trailers, user recommendations. The default response is already complete; no flags needed.
Responses are **Cloudflare-cached by Simkl ID**, so repeat lookups of popular titles are near-free. Parallel requests against this endpoint are explicitly allowed (see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed)).
**Cache invalidation is automatic.** When Simkl updates the underlying record (admin edits, automated metadata refresh, image swap, related-titles change, etc.), the corresponding Cloudflare cache entry is purged server-side. The next call to this endpoint returns the fresh data — there's no TTL to wait out and no client-side cache-busting needed. Your own app-level cache, if any, still has to be invalidated by your client.
Use a Simkl ID for the lookup. If you only have an external ID, resolve it via [`GET /redirect`](/api-reference/redirect) first.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Get the user's rated items, filtered by type and rating
Source: https://api.simkl.org/api-reference/simkl/get-user-ratings
/openapi.json get /sync/ratings/{type}/{rating}
Returns the items **the user has rated themselves** — filtered to one type (movies, shows, or anime) and one or more rating values.
#### Path parameters
| Segment | What to send |
|---|---|
| `type` | `movies`, `shows`, or `anime`. |
| `rating` | A single value `1`–`10`, or a comma-separated list like `8,9,10`. |
Example: `GET /sync/ratings/movies/9,10` returns every movie the user rated 9 or 10.
#### Want every rated item in one type?
Pass the full list as a CSV: `GET /sync/ratings/movies/1,2,3,4,5,6,7,8,9,10`. The response then includes only items the user actually rated (any value from 1 to 10) and skips unrated library items.
#### Common query parameters
Same as [`GET /sync/all-items`](/api-reference/simkl/get-all-items): `extended`, `date_from`, `episode_watched_at`, `memos`, `language`. Use `date_from` after [`GET /sync/activities`](/api-reference/simkl/get-activities) tells you `rated_at` has bumped to pull only the newly-changed ratings.
**This is the user's own 1–10 scores — not the Simkl community average.** If you want Simkl's public ratings for items in the user's watchlist, use [`GET /ratings/{type}`](/api-reference/simkl/get-watchlist-ratings) instead.
**Already pulling the full library via [`GET /sync/all-items`](/api-reference/simkl/get-all-items)?** Each item there already carries `user_rating` (1–10 or `null`) and `user_rated_at`. Filter client-side with `item.user_rating === 9` instead of calling this endpoint. Use `/sync/ratings/{type}/{rating}` only when you want the server to do the filtering — typically the first load of a bulk-rating UI that just needs "all my 9s and 10s" without downloading the whole library.
#### Silent fallbacks (no errors)
The API is forgiving here and won't return a `400` when you pass something odd — it just returns an empty or unexpected result. Worth knowing so you don't think the user has no ratings when the URL was actually wrong:
| URL | What you get back |
|---|---|
| `/sync/ratings/tv_shows/9` (any unrecognized type word) | `200` with **cross-type** results at rating 9 — the type segment is silently ignored, not validated. The correct word is `shows`, not `tv_shows`. |
| `/sync/ratings/movies/99` (out of range) | `200 {}` — the value is accepted but never matches any 1–10 rating. |
| `/sync/ratings/movies` (rating segment omitted) | `200` with the user's **entire movie library**, including unrated items (each carries `user_rating: null`). Effectively the same as [`GET /sync/all-items/movies`](/api-reference/simkl/get-all-items) — prefer that route since it's the documented one. |
Two-phase model (initial pull → activities-checked delta loop), `date_from` semantics, deletion reconciliation, edge cases, and reference implementations in Node and Python.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Get the authenticated user's settings
Source: https://api.simkl.org/api-reference/simkl/get-user-settings
/openapi.json post /users/settings
Returns the authenticated user's profile (name, avatar, bio, location, age) and account settings (timezone, plan type). `POST` for historical reasons — no body.
#### Response shape
```json
{
"user": {
"name": "username",
"joined_at": "2018-01-15T00:00:00Z",
"gender": "Male",
"avatar": "https://simkl.in/avatars/.../user_100.jpg",
"bio": "I like anime.",
"loc": "Spain",
"age": 28
},
"account": {
"id": 12345,
"timezone": "Europe/Madrid",
"type": "vip"
}
}
```
`account.type` is one of `free`, `pro`, `vip`. Fields like `gender` are blank if the user disabled them in their privacy settings.
#### When to refetch
User settings are **set-and-forget** in practice — most users configure their timezone / date format / privacy preferences once and never touch them again. **Don't refetch on a timer or on every app launch / wake from background.** Instead, gate the refetch on [`/sync/activities`](/api-reference/simkl/get-activities), which returns a `settings.all` timestamp that bumps when the user changes any account-level preference. Refetch only when that timestamp moves since the value you saved last time. Most launches will do **zero** extra calls. Full pattern + code example at [Dates and timezones → User timezone preference](/conventions/dates#user-timezone-preference).
# Get a user's watch statistics
Source: https://api.simkl.org/api-reference/simkl/get-user-stats
/openapi.json post /users/{user_id}/stats
**The most expensive call in the Simkl API. Only fire it on an explicit user action.**
Stats are computed **live on every request** — there is no edge cache and no precomputed result cache. The server walks the user's entire watch history across all three catalogs (movies, TV, anime), looks up the runtime of every completed episode and movie, and aggregates everything from scratch. Response time scales with the size of the user's library.
**OK to call:** when the user opens a "My stats" / "Year in review" / profile screen, or taps a refresh button on a stats widget.
**Do not call:** on app launch, on resume from background, in any polling loop, speculatively to "warm" data, or for every user in a list (e.g. a friends leaderboard — batch via lazy loading). Apps that hammer this endpoint risk rate-limit throttling on the `client_id`.
Returns aggregate stats for the given user — total movies / shows / anime watched, total time spent, episode counts, last-week activity, and basic profile info.
The `user_id` must be a **positive integer** — the numeric Simkl id of the target account. To fetch stats for the **authenticated user**, call [`POST /users/settings`](/api-reference/simkl/get-user-settings) once at app start and cache `account.id`, then pass that value here.
Public profiles can be fetched without a bearer token (clientId-only). Private profiles require either a bearer token belonging to the target user, or a connection the target user has granted the requester (otherwise `403 private_profile`).
This is `POST` for historical reasons — there is no request body.
#### Response shape
```json
{
"user": {
"id": 51,
"name": "username",
"joined_at": "2018-01-15T00:00:00Z",
"avatar": "https://simkl.in/avatars/.../user_100.jpg",
"gender": "Male",
"loc": "Spain",
"age": 28,
"type": "vip"
},
"total_mins": 78230,
"movies": {
"total_mins": 18000,
"plantowatch": { "mins": 0, "count": 12 },
"completed": { "mins": 18000, "count": 200 },
"dropped": { "mins": 0, "count": 1 }
},
"tv": {
"total_mins": 35000,
"watching": { "watched_episodes_count": 23, "count": 4, "left_to_watch_episodes": 12, "left_to_watch_mins": 600, "total_episodes_count": 35 }
},
"anime": {... },
"watched_last_week": { "total_mins": 320, "movies_mins": 60, "tv_mins": 200, "anime_mins": 60 }
}
```
The `user` block is omitted when the target user has not loaded any data (e.g. brand-new accounts); only `total_mins` and the per-domain blocks are guaranteed.
#### Errors
| Code | When |
|---|---|
| `404 user_id_failed` | `user_id` is `0` or any non-positive integer. There is **no shortcut** for the authenticated user — always pass a real numeric id. |
| `403 private_profile` | The target user's profile is private and the requester does not have access. |
# Look up watched status for items
Source: https://api.simkl.org/api-reference/simkl/get-watched
/openapi.json post /sync/watched
POST an array of items you already know about; Simkl returns a parallel array telling you, **per item**, whether it's in the user's library, its current status, last-watched timestamp, and (optionally) per-episode breakdown.
Use this **only** when you don't already cache the user's full library locally — typical case is a media-server plugin or a deep-link landing page that needs to check "is this title in the user's tracker yet?" for a handful of specific titles, without syncing the whole library first.
> ⚠️ **Don't use this endpoint if your app already pulls [`GET /sync/all-items/{type}/{status}`](/api-reference/simkl/get-all-items).** The full-library response already contains the same per-item watch state, statuses, and last-watched timestamps that `/sync/watched` returns — your local cache has the answer. Calling both is wasted requests, counts twice against your rate-limit quota, and is one of the patterns that gets an app's `client_id` suspended. The correct loop for tracker apps that sync the full library is the two-phase model: full pull once, then `/sync/activities`-gated incremental refresh — see the [Sync guide](/guides/sync).
#### Item identification
Each input item carries one or more IDs. Simkl resolves to the canonical record before looking up watch state, so any of these work:
| ID style | Example |
|---|---|
| Simkl ID | `{ "ids": { "simkl": 2090 } }` |
| IMDb / TMDB / TVDB / MAL / AniDB / AniList / Kitsu | `{ "ids": { "imdb": "tt1520211" } }` |
| Title + year fallback | `{ "title": "Inception", "year": 2010 }` |
Pair `season` + `episode` on an item to ask 'has the user watched this specific episode?' instead of 'is this title in the library?'.
#### Query params
| Param | Effect |
|---|---|
| `extended=episodes` | Include per-episode breakdown (`seasons[].episodes[]` arrays) for shows/anime. **Limit: 100 items per call** when this is set — sending more triggers `400 max_items`. |
| `extended=specials` | Include specials (season `0`). Only effective when combined with `episodes`. |
| `extended=counters` | When sent **alone** (without `episodes`), the `seasons[]` array is included but its `episodes[]` arrays are omitted — useful when you only want totals without the per-episode payload. When sent **together with `episodes`**, the per-episode arrays are still included. |
Multiple values are comma-separated: `extended=episodes,specials`.
#### Response shape (per item)
The response is an array of the same length as the request, in the same order. Each entry echoes the input identifiers and adds:
| Field | When present | Notes |
|---|---|---|
| `result` | always | `true` if the user has watched (or is watching) this item. `false` if Simkl matched the IDs but the item isn't in the user's library. `"not_found"` if Simkl couldn't match the IDs at all — in this case only `result` is returned, no `simkl`/`list`/etc. |
| `simkl` | when `result` ≠ `"not_found"` | Canonical Simkl ID. |
| `list` | when matched | Current watchlist status (`watching`, `completed`, `plantowatch`, `hold`, `dropped`) or `null` if not in any list. |
| `last_watched_at` | when matched | ISO-8601 timestamp of the most recent watch event, or `null` if never watched. |
| `episodes_total` / `episodes_aired` / `episodes_to_be_aired` / `episodes_watched` | with `extended=episodes` or `extended=counters` (shows/anime only) | Aggregate counts across all seasons. |
| `seasons[]` | with `extended=episodes` or `extended=counters` (shows/anime only) | Per-season `{number, episodes_total, episodes_aired, episodes_to_be_aired, episodes_watched}`. With `extended=episodes` alone, each season also includes an `episodes[]` array (per-episode `{number, watched, aired, last_watched_at}`). With `extended=counters` alone, `episodes[]` is omitted. |
**Empty body quirk.** Sending an empty array `[]` returns the literal `null` (not `[]`). Treat both as 'no items to check'.
#### Errors
| Status | `error` | When |
|---|---|---|
| 400 | `max_items` | More than 100 items in a single call when `extended=episodes` (or any other `extended` value that triggers per-episode loading) is set. |
Two-phase model (initial pull → activities-checked delta loop), `date_from` semantics, when to use `/sync/watched` vs `/sync/all-items`, and reference implementations.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Community ratings for items in the user's watchlist
Source: https://api.simkl.org/api-reference/simkl/get-watchlist-ratings
/openapi.json get /ratings/{type}
**This endpoint returns Simkl's *community* ratings** (the public average + droprate + vote counts) for every item in the user's watchlist — not the user's own 1-10 scores. If you want **the user's own ratings**, every [`GET /sync/all-items`](/api-reference/simkl/get-all-items) response already carries `user_rating` (1-10 or `null`) per item — filter that client-side. For server-side filtering by score (e.g. "give me only items I rated 9 or 10"), see [`GET /sync/ratings/:type/:rating`](/api-reference/simkl/get-user-ratings).
Bulk Simkl-rating lookup for items across the user's watchlist. Useful for ranking the user's library against community ratings (e.g. "what's the highest-community-rated movie in my Plan-to-Watch?").
Returns an array of `{id, simkl: {rating, votes, droprate}}` for every item in the requested watchlist statuses. Pair with [`GET /movies/{id}`](/api-reference/simkl/get-movie) / [`GET /tv/{id}`](/api-reference/simkl/get-tv-show) / [`GET /anime/{id}`](/api-reference/simkl/get-anime) (Cloudflare-cached) when you need the full record for any individual item.
#### Path
| Segment | Values | Notes |
|---|---|---|
| `type` | `movies`, `tv`, `anime`, `all` | Required. Use `/ratings/all` to get every type in one response. |
#### Query
| Param | Required | Notes |
|---|---|---|
| `user_watchlist` | yes | Comma-separated list of watchlist statuses to include. Any of `watching`, `plantowatch`, `hold`, `completed`, `dropped`. Use `1` (or any non-empty value) as a shorthand for "all statuses". Without this param the request silently falls through to a different code path and returns `200 null` — always supply it. |
| `fields` | no | Comma-separated extra blocks to include alongside the default `simkl` block. See the *Fields values* table below. |
#### Fields values
| `fields` value | Adds |
|---|---|
| `simkl` *(default)* | `simkl: {rating, votes, droprate}` per item. |
| `ext` | `imdb: {rating, votes}` and/or `mal: {rating, votes, rank}` (only the providers Simkl has on file for the title). |
| `rank` | `rank` integer — Simkl's catalog rank for the item. |
| `release_status` | Human-readable release status (e.g. `Ended`, `Continuing`). |
| `year` | `release_year` integer. |
| `link` | Canonical Simkl URL for the item. |
Combine multiple with commas: `fields=simkl,ext,year,rank`. Unknown values (including `reactions` and `has_trailer`, which are valid on the hidden single-item rating endpoint but **not** here) are silently ignored.
#### Auth
Requires `Authorization: Bearer ` plus the standard `client_id` / `app-name` / `app-version` URL params.
For the user's **own** ratings (the 1-10 scores they've assigned), use [`GET /sync/ratings/:type/:rating`](/api-reference/simkl/get-user-ratings) instead — that's the user-rated-by-them endpoint, this one is community-rating-of-everything-in-their-list.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Resolve any ID and redirect to Simkl
Source: https://api.simkl.org/api-reference/simkl/redirect
/openapi.json get /redirect
A passive helper endpoint that **`301`-redirects** to a Simkl page (or an action) given any combination of IDs or a title.
It's designed for two situations:
1. **Linking to Simkl when you don't have the Simkl ID** — turn an IMDB / TMDB / TVDB / MAL / AniDB ID, or a title + year, into a clickable Simkl URL.
2. **Getting the Simkl ID with minimal info** — the cheapest way to translate an external ID into a Simkl ID. **Read the `Location` response header** and parse the `simkl_id` out of the URL path — no JSON to parse.
> ⚠️ **Do not follow the 301.** Read the `Location` header directly. Use `curl -I`, `requests.get(..., allow_redirects=False)`, `fetch(..., { redirect: 'manual' })`, or your client's equivalent. The destination is a public-facing page (simkl.com HTML, YouTube, Twitter intent, or `/oauth/authorize`) and contains no API data — following the redirect wastes bandwidth and can break (CORS, auth, destination-host rate limits). This applies to **HTTP clients, scripts, automated tools, AI agents, and LLM-driven workflows alike**.
>
> **What to do next, after reading the `Location` header:**
> - **If you only need the Simkl ID** — parse it out of the URL path (e.g. `https://simkl.com/tv/17465/...` → `17465`) and **stop**. Don't call anything else.
> - **If you also need the full record** (title, overview, poster, fanart, ratings, trailers, etc.) — pass the parsed Simkl ID to the matching Cloudflare-cached detail endpoint: [`GET /movies/{id}`](/api-reference/simkl/get-movie), [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`GET /anime/{id}`](/api-reference/simkl/get-anime), [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes), or [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes). Popular titles come straight from edge cache.
>
> Full reference table of stop-at-301 invocations for popular HTTP clients in the [Redirect overview](/api-reference/redirect#use-case-2-resolve-an-external-id-to-a-simkl-id).
Like every Simkl endpoint, requests must include the [required URL parameters](/conventions/headers#required-url-parameters) (`client_id`, `app-name`, `app-version`) and a `User-Agent` header. No `Authorization` token is needed except for `to=watched`, which signs the user in if they aren't already.
#### `to=` action modes
| Mode | What the redirect points at |
|---|---|
| `simkl` *(default)* | The matching Simkl page (`https://simkl.com/movies/{id}/{slug}`, `tv/...`, `anime/...`). |
| `trailer` | The trailer URL (typically YouTube). |
| `twitter` | A `twitter.com/intent/tweet` URL with the title and a Simkl link prefilled. |
| `watched` | Marks the item watched on the user's account. If the user isn't signed in, Simkl redirects to `/oauth/authorize` first. |
#### Identifier parameters
Pass any combination — the more, the more accurate the match. Most can stand alone:
| Param | Notes |
|---|---|
| `simkl` | Simkl ID. |
| `imdb` | IMDB ID, or a full IMDB URL. |
| `tmdb` | TMDB ID. **Requires `type=movie` or `type=tv`** to disambiguate — TMDB has no anime type (anime shows are filed under `tv` on TMDB; Simkl routes them to its anime catalog automatically once resolved). |
| `tvdb` | TVDB ID. |
| `mal`, `anidb`, `anilist`, `kitsu`, `livechart`, `anisearch`, `animeplanet` | Anime-specific IDs. |
| `crunchyroll` | Crunchyroll show or episode ID/slug. |
| `netflix`, `hulu` | Streaming-service IDs (beta). |
| `title`, `year` | Title-based fallback. Pair with `type` for best results. |
| `season`, `episode` | Episode targeting (`season` defaults to `1`). Movies are ignored when either is set. |
| `ep_title` | Episode title used in the tweet text when `to=twitter`. |
| `type` | `movie`, `tv`, or `anime`. Required for `tmdb`; optional otherwise (`show` matches both `tv` and `anime`). |
#### Response
- Status: **`301 Moved Permanently`**
- `Location`: the resolved URL (Simkl page, YouTube, Twitter intent, or the OAuth authorize page when `to=watched` and the user isn't signed in).
- `Cache-Control: no-store` — never cache the redirect itself; cache the resolved URL on your side if you need to.
#### Why "lowest cost" for ID resolution
`GET /redirect?to=simkl&imdb=…` returns a `Location` header like `https://simkl.com/movies/472214/inception`. The number after `/movies/`, `/tv/`, or `/anime/` is the Simkl ID. Compared to `GET /search/id`:
- **No JSON parse** — read the `Location` header, regex out the ID.
- **Tiny payload** — HTTP headers only, no response body.
Use this for "I have an IMDB ID, give me the Simkl ID" lookups when you don't need the rest of the media object yet.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Remove from History
Source: https://api.simkl.org/api-reference/simkl/remove-from-history
/openapi.json post /sync/history/remove
Removes items from the user's watched history. **Body shape is identical to [`POST /sync/history`](/api-reference/simkl/add-to-history)** — the same `movies[]`, `shows[]`, and granularity rules apply.
#### Granularity
What you send determines what gets removed.
**Movie or show with no `seasons` and no `episodes`** — the item is **removed from the user's library entirely** (any watch history AND the watchlist entry). Equivalent to the user clicking "Remove from list" on the title page.
```json
{ "shows": [{ "ids": {...} }] }
```
**Show with `seasons[]` entries that omit `episodes`** — every episode in those seasons is unmarked as watched. The show stays in the user's library.
```json
{ "shows": [{ "ids": {...}, "seasons": [{ "number": 2 }] }] }
```
**Show with `seasons[].episodes[]`** — only the listed episodes are unmarked. The show stays in the user's library.
```json
{
"shows": [{
"ids": {...},
"seasons": [{
"number": 1,
"episodes": [{ "number": 1 }, { "number": 2 }]
}]
}]
}
```
**Show with top-level `episodes[]` shorthand** — treated as `seasons: [{ number: 1, episodes: [...] }]`. Convenient for single-season shows; otherwise prefer the explicit form.
```json
{ "shows": [{ "ids": {...}, "episodes": [{ "number": 1 }] }] }
```
#### Response shape
Status: **201 Created**.
```json
{
"deleted": {
"movies": ,
"shows": ,
"episodes":
},
"not_found": {
"movies": [],
"shows": []
}
}
```
**`not_found` only has `movies` and `shows`** — there's no `not_found.episodes` array even when you tried to remove specific episodes. If the parent show isn't matchable, the show object lands in `not_found.shows` and no episodes are touched. If the show is matchable but a specific episode number doesn't exist, the call still counts as success and `episodes` in `deleted` reflects only the episodes that were actually unmarked.
**Anime titles** go in `shows[]` (with anime-only IDs like `anidb` / `mal` / `anilist` inside each item's `ids` object). There is no top-level `anime[]` array on this endpoint — items sent under one are silently ignored. See [Anime under shows[]](/conventions/standard-media-objects#anime).
The mirror endpoint that adds history. Same body shape; this page is the removal side.
Two-phase model (initial pull → activities-checked delta loop), deletion reconciliation, and reference implementations.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Remove Ratings
Source: https://api.simkl.org/api-reference/simkl/remove-ratings
/openapi.json post /sync/ratings/remove
Clears the user's ratings on the listed items. **Body shape is identical to [`POST /sync/ratings`](/api-reference/simkl/add-ratings) minus the `rating` field** — the IDs alone are enough to identify which entries to un-rate.
**Removing a rating does NOT remove the item from the user's watchlist** — it only clears the rating value. The item keeps its watchlist status (`watching` / `completed` / etc.) and stays in the library. To remove an item from the user's library entirely, use [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history).
#### Response shape
Status: **201 Created**.
```json
{
"deleted": {
"movies": ,
"shows":
},
"not_found": {
"movies": [],
"shows": []
}
}
```
**No `anime` key** — anime is folded under `shows` on both the request and response side, same as on [`POST /sync/ratings`](/api-reference/simkl/add-ratings). Send anime titles in `shows[]` with anime-only IDs (`mal`, `anidb`, `anilist`, `kitsu`) inside each item's `ids` object.
**`deleted` counts matched items**, not items that actually had a rating. If you send a movie that Simkl resolves to a canonical record but the user never rated it, that movie still counts in `deleted.movies`. The call is idempotent — sending the same body twice has the same end state on the second call as on the first.
The mirror endpoint that sets ratings. Same body shape minus the `rating` field on each item.
The two-phase sync model and how rating activity surfaces in `/sync/activities`.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Check-in
Source: https://api.simkl.org/api-reference/simkl/scrobble-checkin
/openapi.json post /scrobble/checkin
A **fire-and-forget version of [`/scrobble/start`](/api-reference/simkl/scrobble-start)**. Same effect on the user's [dashboard](https://simkl.com/) — the title appears in the **"Watching now"** widget with an animated, runtime-extrapolated progress bar — but you don't need to follow up with `pause` / `stop` events. Simkl computes progress server-side from `(now − checkin time) ÷ runtime`; when that reaches 100%, the title is auto-marked watched.
> **Auto-completion timing:** Once the computed progress reaches 100%, marking the item as watched can take anywhere from **0 to 2 minutes**. Some check-ins finalize instantly; others sit at 100% briefly while the background worker picks them up. Don't treat the delay as a failure — if you need to know exactly when it lands, check [`GET /sync/activities`](/api-reference/simkl/get-activities) after the runtime expires; when the relevant `completed` / `watching` timestamp bumps, refresh via [`GET /sync/all-items/{type}/{status}?date_from=…`](/api-reference/simkl/get-all-items). That's the same incremental loop documented in the [Sync guide](/guides/sync) — no extra calls beyond what a normal sync would already do.
The user can browse and clean up active check-ins at the [Playback progress manager](https://simkl.com/my/history/playback-progress-manager/).
#### When to use checkin vs the start / pause / stop loop
| Situation | Use |
|---|---|
| You have real player events (play / pause / stop) and want exact progress | [`/scrobble/start`](/api-reference/simkl/scrobble-start) → [`/pause`](/api-reference/simkl/scrobble-pause) → [`/stop`](/api-reference/simkl/scrobble-stop) loop |
| You can't reliably hook into pause / stop (some embedded players, casting flows, hardware AV-out, social "I'm watching this" buttons) | `checkin` |
| You just want to record a watch after the fact, no live status | [`POST /sync/history`](/api-reference/simkl/add-to-history) |
#### Seek and scrub behavior
No progress to update — the user can scrub or seek freely after check-in. The server's runtime extrapolation doesn't track real player position, so a user who checks in and then walks away is also auto-marked watched at the calculated runtime expiry. That's a feature, not a bug, for fire-and-forget integrations.
> **Note:** A 20-second per-user lock collision returns HTTP `400` with `RATE_LIMIT`, not `429` — the lock failure is treated as a malformed request from a duplicate-fire client.
Real-time playback tracking — `/start`, `/pause`, `/stop` lifecycle, paused-playback resumption across devices, when scrobble auto-completes, and the difference between `/scrobble/checkin` (fire-and-forget) and `/scrobble/start` (active tracking).
Alternative to `episode.season` + `episode.number`: pass `episode.ids` with `tvdb` or `anidb` to identify the exact episode by external episode ID. Useful for media-server integrations that have a TVDB or AniDB episode ID but not the season/number mapping. (Episode-level `imdb` and `tmdb` IDs are **not** accepted — those exist only at the show/movie level. Use the `show`/`anime` object's `ids` for those.) If both forms are sent, `episode.ids` takes precedence.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Pause
Source: https://api.simkl.org/api-reference/simkl/scrobble-pause
/openapi.json post /scrobble/pause
Saves the current `progress` as a **resumable playback** that any signed-in device can fetch via [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions) and resume with [`/scrobble/start`](/api-reference/simkl/scrobble-start). This is how Simkl powers cross-device "Continue Watching." Does **not** mark the item watched. See the [Playback overview](/api-reference/playback) for retention rules and the user-facing manager.
The body shape is identical to [`/scrobble/start`](/api-reference/simkl/scrobble-start).
#### Seek and scrub behavior
The `progress` you send is whatever the playhead is at the moment of pause — it doesn't have to be larger than the prior `start`'s progress. A user who scrubs backward and pauses sends a smaller `progress`; that's correct and the server stores it. Don't call this endpoint on seek events themselves.
> **Note:** A 20-second per-user lock collision returns HTTP `400` with `RATE_LIMIT`, not `429` — the lock failure is treated as a malformed request from a duplicate-fire client.
Real-time playback tracking — `/start`, `/pause`, `/stop` lifecycle, paused-playback resumption across devices, when scrobble auto-completes, and the difference between `/scrobble/checkin` (fire-and-forget) and `/scrobble/start` (active tracking).
Alternative to `episode.season` + `episode.number`: pass `episode.ids` with `tvdb` or `anidb` to identify the exact episode by external episode ID. Useful for Plex / media-server integrations that have a TVDB or AniDB episode ID but not the season/number mapping. (Episode-level `imdb` and `tmdb` IDs are **not** accepted — those exist only at the show/movie level. Use the `show`/`anime` object's `ids` for those.) If both forms are sent, `episode.ids` takes precedence.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Start
Source: https://api.simkl.org/api-reference/simkl/scrobble-start
/openapi.json post /scrobble/start
Creates or replaces the user's active "watching now" session for the given item. Call this when playback begins, or to resume a previously paused session.
#### Body shape
Send `progress` plus exactly one of `movie`, `show`+`episode`, or `anime`+`episode`. See [Standard media objects](/conventions/standard-media-objects).
| Field | Type | Notes |
|---|---|---|
| `progress` | float | 0–100, max 2 decimals. Response normalizes to `75` not `75.00`. |
| `movie` / `show` / `anime` | object | Title + year + ids. `simkl` ID alone is enough. |
| `episode` | object | `season` + `number`, or `ids`. Required for shows/anime. |
#### Behavior
- Replaces any existing session for this item and clears prior pauses.
- Auto-expires after the calculated remaining runtime.
- If a previous start/checkin reached **≥ 80 %** before this call, it is **auto-scrobbled** (marked watched) before the new session starts.
- Response shape: `id`, `action`, `progress`, plus the media object with `ids` (incl. external links Simkl knows about) and an `episode` block. For anime, the response includes both AniDB-canonical `season`/`number` and original `tvdb_season`/`tvdb_number`.
#### Seek and scrub behavior
Don't call `/scrobble/start` on a seek event. Only call it when playback actually begins or resumes (typically the player's `play` event). When a user scrubs to a different position before pressing play, just update your local progress; the eventual `play` event fires the call with the new value.
#### Errors
| Code | When |
|---|---|
| `400empty_field` | No `movie`, `show`, or `anime` in the body. |
| `400RATE_LIMIT` | A 20-second per-user lock collision — another scrobble write for this user landed within the window. Note: HTTP `400`, **not** `429` — the lock failure is treated as a malformed request from a duplicate-fire client. |
| `401user_token_failed` | Missing / invalid bearer token. |
| `404id_err` | Item could not be matched. |
Real-time playback tracking — `/start`, `/pause`, `/stop` lifecycle, paused-playback resumption across devices, when scrobble auto-completes, and the difference between `/scrobble/checkin` (fire-and-forget) and `/scrobble/start` (active tracking).
Alternative to `episode.season` + `episode.number`: pass `episode.ids` with `tvdb` or `anidb` to identify the exact episode by external episode ID. Useful for Plex / media-server integrations that have a TVDB or AniDB episode ID but not the season/number mapping. (Episode-level `imdb` and `tmdb` IDs are **not** accepted — those exist only at the show/movie level. Use the `show`/`anime` object's `ids` for those.) If both forms are sent, `episode.ids` takes precedence.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Stop
Source: https://api.simkl.org/api-reference/simkl/scrobble-stop
/openapi.json post /scrobble/stop
Finalizes the user's playback session. The `action` field in the response tells you what Simkl did:
| `progress` | `action` | Result |
|---|---|---|
| **≥ 80** | `scrobble` | Item is marked watched. |
| **< 80** | `pause` | Session is saved as a paused playback. |
When `progress < 80`, the session is kept as a **resumable playback**, retrievable cross-device via [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions). When `progress ≥ 80`, the item is marked watched and no playback is saved. See the [Playback overview](/api-reference/playback) for retention.
Body shape is identical to [`/scrobble/start`](/api-reference/simkl/scrobble-start).
#### Duplicate prevention
Stopping a session that's already been finalized within the past hour returns **`409 Conflict`** with `watched_at` and `expires_at` so you know when the prior scrobble expires:
```json
{
"watched_at": "2024-05-01T18:00:00-05:00",
"expires_at": "2024-05-01T19:00:00-05:00"
}
```
#### Seek and scrub behavior
The ≥80% auto-scrobble rule applies to the `progress` you send with this call — not to anywhere the user temporarily scrubbed during playback. A user who scrubbed to 95% mid-watch but then rewinds and stops at 30% sends `progress: 30`, and the server stores `action: "pause"`. Only the value at the moment of `stop` matters.
> **Note:** A 20-second per-user lock collision returns HTTP `400` with `RATE_LIMIT`, not `429` — the lock failure is treated as a malformed request from a duplicate-fire client.
Real-time playback tracking — `/start`, `/pause`, `/stop` lifecycle, paused-playback resumption across devices, when scrobble auto-completes, and the difference between `/scrobble/checkin` (fire-and-forget) and `/scrobble/start` (active tracking).
Alternative to `episode.season` + `episode.number`: pass `episode.ids` with `tvdb` or `anidb` to identify the exact episode by external episode ID. Useful for Plex / media-server integrations that have a TVDB or AniDB episode ID but not the season/number mapping. (Episode-level `imdb` and `tmdb` IDs are **not** accepted — those exist only at the show/movie level. Use the `show`/`anime` object's `ids` for those.) If both forms are sent, `episode.ids` takes precedence.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Find an item by file name
Source: https://api.simkl.org/api-reference/simkl/search-by-file
/openapi.json post /search/file
Identify a single video file the user just opened. Pass one filename and Simkl returns the matched movie, or the show + the specific episode the filename names. Built for desktop scrobblers and player overlays that need to recognize what the user is currently watching — apps that don't already have parsed metadata from a media server.
**Not for library scraping.** Calling `/search/file` for every file in a user's library is against the rate limits and will get the integration throttled. If you already have a media server (Plex, Kodi, Jellyfin, Emby, …) it already has parsed metadata for every file — use that. This endpoint is for ad-hoc, one-file-at-a-time identification.
The server normalizes the filename (release tags, resolution, codec markers, group names) and matches it against the Simkl catalog — so messy real-world filenames like `Stranger.Things.S01E03.1080p.WEB.x264-GROUP.mkv` work fine.
#### Body fields
| Field | Required | Notes |
|---|---|---|
| `file` | yes | The file name or `/path/to/folder/file.mkv`. The alias `File` (capital F) is also accepted for legacy clients. |
| `part` | no | 1-based part index for multi-part files (`S01E01E02.mkv` is two episodes — pass `part: 2` for the second). Default `1`. |
| `process` | no | Optional pre-processing hint forwarded to the parser. Most clients can omit. |
| `hash` | no | Optional file hash for additional disambiguation. |
#### Response shape — discriminated by `type`
The top-level `type` field tells you which variant you got:
| `type` | When | Top-level blocks present |
|---|---|---|
| `"movie"` | Filename matched a movie | `movie` |
| `"show"` | Filename matched a TV/anime show but no specific episode | `show` |
| `"episode"` | Filename matched a TV/anime episode | `show` + `episode` |
Movies and shows carry an `ids` block populated by Simkl's link database — typically `simkl` + several external IDs (`imdb`, `tmdb`/`tmdbtv`, `tvdb`, anime sources like `mal` / `anidb` / `anilist` / `kitsu` / `crunchyroll`, plus slugs for Letterboxd / Trakt / TVDB). Anime episodes return as `type: "episode"` with the standard show+episode blocks — the file parser doesn't distinguish anime from TV at the top level.
#### Edge responses (status 200)
| Body | Meaning |
|---|---|
| `null` | Empty or malformed request body — no `file` field present. |
| `[]` | Parser ran but couldn't match the filename to anything in the database. |
Both are 200 — there's no 404 or 400 for these cases. Treat both as "no match" in client code.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Find items by external ID
Source: https://api.simkl.org/api-reference/simkl/search-by-id
/openapi.json get /search/id
> ## ⚠️ Prefer [`GET /redirect`](/api-reference/redirect) + a cached detail endpoint over `/search/id` for almost every external-ID lookup
>
> The recommended two-step flow is materially cheaper, faster, and edge-cached:
>
> **Step 1 — Resolve the external ID to a Simkl ID.** Call [`GET /redirect?to=simkl&=…`](/api-reference/simkl/redirect) and **read** (don't follow) the `Location` header. Parse the Simkl ID out of the URL path. No JSON body to download, no JSON to parse — just HTTP headers.
>
> **Step 2 — Fetch the full record from the cached detail endpoint.** Use the parsed Simkl ID with the matching:
> - Movies → [`GET /movies/{simkl_id}`](/api-reference/simkl/get-movie)
> - TV shows → [`GET /tv/{simkl_id}`](/api-reference/simkl/get-tv-show)
> - Anime → [`GET /anime/{simkl_id}`](/api-reference/simkl/get-anime)
> - Episode lists → [`GET /tv/episodes/{simkl_id}`](/api-reference/simkl/get-tv-episodes) or [`GET /anime/episodes/{simkl_id}`](/api-reference/simkl/get-anime-episodes)
>
> These detail endpoints are **Cloudflare-cached by Simkl ID** with **automatic server-side cache invalidation** on metadata updates. Popular titles come straight from the edge cache and cost almost nothing.
>
> **Why this beats `/search/id`:**
> - `/redirect` returns just HTTP headers (no JSON body). `/search/id` returns JSON every call.
> - The detail endpoints are Cloudflare-cached. `/search/id` is a search query that hits origin every time.
> - Two requests both cheap > one request that always hits origin.
> - Concurrent / parallel lookups against the cached detail endpoints are explicitly allowed (see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed)). `/search/id` should be called sequentially.
>
> **When `/search/id` is still the right call** (rare):
> - You need the [legacy response shape](#response) for a code path you can't change.
> - You need a type-agnostic lookup that returns the `type` field upfront without parsing the `Location` URL.
---
Look up Simkl records by any external ID — IMDB, TMDB, TVDB, MAL, AniDB, AniList, Kitsu, anisearch, anime-planet, livechart, letterboxd, Netflix, Trakt slug. Pass the ID as a query parameter (e.g. `?imdb=tt4574334`).
#### Response shape (per item)
```json
{
"type": "anime",
"title": "Attack on Titan",
"poster": "39/396870bc78f2ba7e",
"year": 2013,
"status": "ended",
"total_episodes": 75,
"anime_type": "tv",
"ids": {
"simkl": 39687,
"slug": "attack-on-titan"
},
"mal": {
"id": 16498,
"type": "tv"
}
}
```
`status` is one of `released`, `upcoming`, `ended`, `aired`, `tba`. `total_episodes` is omitted for movies.
Full walkthrough of the two-step `/redirect` → cached detail endpoint flow, with the stop-at-301 reference table for popular HTTP clients and worked recipes in bash, JavaScript, and Python.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Search by text query
Source: https://api.simkl.org/api-reference/simkl/search-by-text
/openapi.json get /search/{type}
Full-text search over the Simkl catalog. Pick a type (`movie`, `tv`, or `anime`) and pass a search term like `john wick` or `john wick 2014`.
**Heads up: `movie` becomes `"movies"` in the response.**
You call `/search/movie` (no `s`) but each item in the response has `endpoint_type: "movies"` (with an `s`). TV and anime don't change.
| You call | Each item's `endpoint_type` is |
|---|---|
| `/search/movie` | `"movies"` ← note the extra `s` |
| `/search/tv` | `"tv"` |
| `/search/anime` | `"anime"` |
Every item in a single response has the same `endpoint_type` — you never get a mixed list back.
#### Path parameter
| Param | Values |
|---|---|
| `type` | `movie`, `tv`, `anime` |
#### Query parameters
| Param | Default | Notes |
|---|---|---|
| `q` | — | **Required.** Text query (matches `title` and `all_titles[]`). For external-ID lookups (IMDb / TMDB / TVDB / etc.), use [`/redirect`](/api-reference/redirect) or [`/search/id`](/api-reference/simkl/search-by-id) instead. |
| `page` | `1` | Hard-capped server-side at `20`. Higher values silently clamp. |
| `limit` | `10` | Hard-capped server-side at `50`. Higher values silently clamp. |
| `extended` | `simple` | `full` adds `all_titles[]`, `url`, `ep_count` (TV/anime), `rank` (nullable), `status` (TV/anime), and a `ratings` block. |
Returns paginated results with `X-Pagination-*` headers — see [Pagination](/api-reference/pagination) for the standard paginator pattern.
#### Per-item fields by mode
| Field | `simple` | `extended=full` | Notes |
|---|---|---|---|
| `title` | ✓ | ✓ | Display title in the user's locale. |
| `title_en` | — | — | **Anime only**, optional even on anime — only when an English-localized title is on file. |
| `title_romaji` | anime only | anime only | **Anime only**, always present on anime items. Currently mirrors `title` for the romaji slot. |
| `year` | ✓ | ✓ | Premiere year. |
| `endpoint_type` | ✓ | ✓ | `"movies"` / `"tv"` / `"anime"`. Same value on every item in one response. |
| `type` | anime only | anime only | **Anime only**: `tv`, `movie`, `ova`, `ona`, `special`, `music`. |
| `poster` | ✓ | ✓ | Image path fragment — see [Image conventions](/conventions/images) for the full URL pattern (`https://simkl.in/posters/{poster}_m.webp`). |
| `ids` | ✓ | ✓ | `{ simkl_id, slug, tmdb? }`. `tmdb` only present when a TMDB link is on file. |
| `all_titles` | — | movies/anime | Aliases / localized variants. Anime sees the most entries. TV items typically don't carry this even on `extended=full`. |
| `url` | — | ✓ | Relative simkl.com URL (with slug). |
| `ep_count` | — | TV/anime | Total episode count when known. |
| `rank` | — | ✓ | Simkl popularity rank. **Nullable** — see below. |
| `status` | — | TV/anime | Closed enum: `tba`, `ended`, `airing`. |
| `ratings.simkl` | — | ✓ | `{ rating, votes }` — only present when votes > 0. |
| `ratings.imdb` | — | ✓ | `{ rating, votes }` — only present when an IMDb rating record exists. |
| `ratings.mal` | — | anime only | `{ rating, votes, rank }` — anime only, only when a MAL record exists. |
#### Nulls — what they mean
| Field | When null | Type |
|---|---|---|
| `rank` | Item not yet ranked, or rank value ≥ 999999 sentinel | [Type 4](/conventions/null-values#type-4) |
| `ep_count` | TV/anime item with no episode count on file yet | [Type 4](/conventions/null-values#type-4) |
| `poster` | No poster image on file | [Type 4](/conventions/null-values#type-4) |
#### Error responses
| Status | When |
|---|---|
| `412 client_id_failed` | Missing or invalid `client_id` |
| `500` | Server error |
No `400` — invalid `page` / `limit` silently clamp to the server caps. No `404` — empty result is `[]` with status `200`.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
# Random pick
Source: https://api.simkl.org/api-reference/simkl/search-random
/openapi.json post /search/random
Returns a random title — perfect for "What should I watch?" features, daily-pick widgets, or seeding recommendation flows. Optionally filter by type, genre, year range, rating, popularity rank, or streaming service availability.
#### Query / body parameters
All filters can be sent as either query parameters or a JSON body — both forms work. When `type` is omitted, the server picks one of `movie` / `tv` / `anime` at random first, then returns a random item from that domain.
| Param | Notes |
|---|---|
| `service` | `simkl` (default), `netflix`, `crunchy`, `hulu`. When set to anything but `simkl`, results are restricted to titles available on that service AND the response includes `{service}_id` + `{service}_url` (e.g. `netflix_id`, `netflix_url`). |
| `type` | `movie`, `tv`, or `anime`. **Omit to let the server pick a random domain first.** |
| `genre` | Comma-separated genre slugs (e.g. `action,thriller`). Genre slugs differ by type — see the per-type lists below. |
| `country` | ISO 3166-1 alpha-2 country code (movies / TV). |
| `year_from` | Default `1990`. |
| `year_to` | Optional. |
| `rank_limit` | Maximum rank to consider (lower = more popular). |
| `rating_from` | Floor rating, 0–10. For TV / movies this filters on IMDb; for anime, on MAL. |
| `rating_to` | Ceiling rating, 0–10. |
| `limit` | Number of items, capped at `50`. **Single object when omitted; array when set.** |
#### Response shapes
| Shape | When |
|---|---|
| `{ simkl_id, simkl_url }` | `limit` omitted → single random item. |
| `[{ simkl_id, simkl_url }, ...]` | `limit` set → array of up to `limit` items. |
| `{ error: "not_found" }` | Filters matched nothing. Still status `200` (no 404). |
When `service != simkl` and a matching service link exists, the item gains `{service}_id` and `{service}_url`. When no link exists, the item still returns but without those extra keys.
#### Genre slugs by type
Slugs are lowercase with spaces normalized to hyphens.
**Movies** (20): action, adventure, animation, comedy, crime, documentary, drama, erotica, family, fantasy, history, horror, music, mystery, romance, science-fiction, thriller, tv-movie, war, western
**TV** (38): action, adventure, animation, awards-show, children, comedy, crime, documentary, drama, erotica, family, fantasy, food, game-show, history, home-and-garden, horror, indie, korean-drama, martial-arts, mini-series, musical, mystery, news, podcast, reality, romance, science-fiction, soap, special-interest, sport, suspense, talk-show, thriller, travel, video-game-play, war, western
**Anime** (46): action, adventure, comedy, drama, ecchi, educational, fantasy, gag-humor, gore, harem, historical, horror, idol, isekai, josei, kids, magic, martial-arts, mecha, military, music, mystery, mythology, parody, psychological, racing, reincarnation, romance, samurai, school, sci-fi, seinen, shoujo, shoujo-ai, shounen, shounen-ai, slice-of-life, space, sports, strategy-game, super-power, supernatural, thriller, vampire, yaoi, yuri
# Sync
Source: https://api.simkl.org/api-reference/sync
Read and write the user's watch history, watchlists, ratings, and playbacks.
The Sync API keeps a user's library in step with Simkl across every device and app — watch history, watchlists (Watching / Plan to Watch / Hold / Dropped / Completed), per-item ratings, and paused playbacks.
This page is a reference index. The strategy and walkthroughs live in the Sync guide:
Two-phase model end-to-end — Phase 1 sequential pull, Phase 2 `/sync/activities` + `date_from` delta loop, deletion reconciliation, when to actually run sync, useful query params, and a Node + Python reference implementation.
## Common request parameters
Every Sync endpoint shares the same auth + identification surface. See [Headers and required parameters](/conventions/headers) for the full reference.
| Param | Where | Notes |
| --------------- | --------- | ----------------------------------------------------------- |
| `client_id` | URL query | Your app's `client_id`. |
| `app-name` | URL query | Lowercase identifier (e.g. `my-app-name`). |
| `app-version` | URL query | App version string (e.g. `1.0`). |
| `User-Agent` | header | `/`. |
| `Authorization` | header | `Bearer ` — required for every Sync endpoint. |
## Supported ID keys
Every Sync write endpoint matches items by the `ids` object. See the full key list (with types and examples) in [Standard media objects → Supported ID keys](/conventions/standard-media-objects#supported-id-keys).
**Anime works under either `shows[]` or `anime[]`.** All Sync write endpoints accept `movies[]`, `shows[]`, `anime[]`, and `episodes[]` as top-level arrays. Anime entries are resolved by `ids` regardless of which wrapper you use — match the field to your data type when known, fall back to `shows[]` when you only have TMDB / TVDB IDs. Caveat: `not_found.shows` carries any unresolved anime entries too (no separate `not_found.anime` bucket). See [Anime in `shows[]` or `anime[]`](/conventions/standard-media-objects#anime).
## Endpoints
`GET /sync/activities` — last-modified timestamps per category. The "is anything new?" gate.
`GET /sync/all-items/{type}/{status}` — both segments optional. Library reads (full or delta).
`POST /sync/history` — mark items watched.
`POST /sync/history/remove` — un-mark watched.
`POST /sync/add-to-list` — move between watchlist statuses.
`POST /sync/watched` — bulk legacy "watched" write.
`POST /sync/ratings` — 1–10 user rating per item.
`POST /sync/ratings/remove` — clear user-set ratings.
`GET /sync/ratings/{type}/{rating}` — list rated items filtered by type and one or more rating values.
`GET /sync/playback` — list saved paused playbacks (optionally narrow with `/{type}` where `{type}` is `episodes` or `movies`).
`DELETE /sync/playback/{id}` — clear a single saved session.
# Trending data files
Source: https://api.simkl.org/api-reference/trending
Pre-built JSON for Simkl's Most Watched lists — Today, Week, Month — for Movies, TV, and Anime. No API key required.
**No auth required.** Trending data is public — send the standard [required URL parameters](/conventions/headers#required-url-parameters) (`client_id`, `app-name`, `app-version`) and a `User-Agent` header, but no user `Authorization` token.
**Which IDs can I send/expect?** All accepted input identifiers and the keys you'll see echoed back in responses are listed at [**Standard media objects → Supported ID keys**](/conventions/standard-media-objects#supported-id-keys). Send every ID you have on writes — Simkl picks the first that resolves and ignores the rest. Reminder: `slug` is **response-only** (never send it on a request).
**Attribution required.** When you display trending data in your app or website, the section title must include **Simkl** alongside **Trending** (or an equivalent like *Most Watched* / *Popular*). Any sensible combination works — feel free to invent your own wording, as long as both ideas appear together. A few examples to get you started:
| | | |
| -------------------------- | ----------------------------- | --------------------------------- |
| `Simkl Trending Movies` | `Trending Movies on Simkl` | `What's Trending on Simkl` |
| `Simkl Trending TV Shows` | `Trending TV on Simkl` | `Now Trending — Simkl` |
| `Simkl Trending Anime` | `Trending Anime on Simkl` | `Trending Now · Powered by Simkl` |
| `Simkl Trending Today` | `Trending Today on Simkl` | `Today on Simkl` |
| `Simkl Trending This Week` | `Trending This Week on Simkl` | `This Week's Top Picks — Simkl` |
| `Simkl Most Watched` | `Most Watched on Simkl` | `Popular on Simkl` |
| `Simkl Top 100 Movies` | `Top 100 on Simkl` | `Hot Right Now — Simkl` |
| `Simkl Top Charts` | `Top Charts on Simkl` | `Charting on Simkl` |
**For commercial use without attribution**, [contact us](/support) — we're happy to discuss licensing.
### Linking back (websites only)
If your client *can* render hyperlinks — websites, browser extensions, web apps — link the title to the matching Simkl Most Watched page. TV apps, consoles, CLIs, and other contexts that can't open external URLs are exempt; the title alone is enough.
simkl.com/movies/best-movies/most-watched
simkl.com/tv/best-shows/most-watched
simkl.com/anime/best-anime/most-watched
Simkl provides pre-built JSON files with trending data **ranked by the number of watchers**. These are the same rankings displayed on Simkl's Most Watched pages for [Movies](https://simkl.com/movies/best-movies/most-watched/), [TV Shows](https://simkl.com/tv/best-shows/most-watched/), and [Anime](https://simkl.com/anime/best-anime/most-watched/).
Each file is available in two sizes: **top 100** (`_100`) or **top 500** (`_500`) items. Titles with the most watchers are returned first.
## At a glance
**Top 100** (`_100`) and **Top 500** (`_500`) per file.
Each file's `Last-Modified` response header tells you exactly when it was generated.
### Update frequency
| Data | Description | Update frequency |
| ------------ | ------------------------------------------ | ---------------- |
| Today | Most watched titles over the last 24 hours | Every hour |
| Week | Most watched titles over the last 7 days | Once a day |
| Month | Most watched titles over the last 30 days | Once a day |
| DVD releases | Latest popular DVD releases (Movies only) | Once a day |
**The file URLs ignore all query strings.** Don't add `?random=...` or `?nocache=...` — the CDN treats every variant as the same resource. Simkl regenerates the files on the schedule above and automatically clears them from the Cloudflare cache, so you'll always get the latest version on your next request — client-side cache-busting won't deliver newer data.
## Pick your trending data file
Movies, TV Shows, and Anime in a **single response** — use these to minimize the number of requests when you need all categories at once. The top-level JSON is an object with `movies`, `tv`, and `anime` arrays.
| Timeframe | Top 100 | Top 500 |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Today | [today\_100.json](https://data.simkl.in/discover/trending/today_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [today\_500.json](https://data.simkl.in/discover/trending/today_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Week | [week\_100.json](https://data.simkl.in/discover/trending/week_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [week\_500.json](https://data.simkl.in/discover/trending/week_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Month | [month\_100.json](https://data.simkl.in/discover/trending/month_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [month\_500.json](https://data.simkl.in/discover/trending/month_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Timeframe | Top 100 | Top 500 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Today | [movies/today\_100.json](https://data.simkl.in/discover/trending/movies/today_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [movies/today\_500.json](https://data.simkl.in/discover/trending/movies/today_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Week | [movies/week\_100.json](https://data.simkl.in/discover/trending/movies/week_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [movies/week\_500.json](https://data.simkl.in/discover/trending/movies/week_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Month | [movies/month\_100.json](https://data.simkl.in/discover/trending/movies/month_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [movies/month\_500.json](https://data.simkl.in/discover/trending/movies/month_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Timeframe | Top 100 | Top 500 |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Today | [tv/today\_100.json](https://data.simkl.in/discover/trending/tv/today_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [tv/today\_500.json](https://data.simkl.in/discover/trending/tv/today_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Week | [tv/week\_100.json](https://data.simkl.in/discover/trending/tv/week_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [tv/week\_500.json](https://data.simkl.in/discover/trending/tv/week_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Month | [tv/month\_100.json](https://data.simkl.in/discover/trending/tv/month_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [tv/month\_500.json](https://data.simkl.in/discover/trending/tv/month_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Timeframe | Top 100 | Top 500 |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Today | [anime/today\_100.json](https://data.simkl.in/discover/trending/anime/today_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [anime/today\_500.json](https://data.simkl.in/discover/trending/anime/today_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Week | [anime/week\_100.json](https://data.simkl.in/discover/trending/anime/week_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [anime/week\_500.json](https://data.simkl.in/discover/trending/anime/week_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
| Month | [anime/month\_100.json](https://data.simkl.in/discover/trending/anime/month_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [anime/month\_500.json](https://data.simkl.in/discover/trending/anime/month_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
Latest popular DVD releases (Movies only) — mirrors [Simkl DVD Releases](https://simkl.com/movies/dvd-releases/). Updated once a day.
| Top 100 | Top 500 |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| [dvd/releases\_100.json](https://data.simkl.in/discover/dvd/releases_100.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | [dvd/releases\_500.json](https://data.simkl.in/discover/dvd/releases_500.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) |
## Fetch it
```bash curl theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -H 'User-Agent: my-app-name/1.0' \
"https://data.simkl.in/discover/trending/today_100.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0"
```
```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}}
const params = new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
'app-name': 'my-app-name',
'app-version': '1.0',
});
const r = await fetch(
`https://data.simkl.in/discover/trending/movies/today_100.json?${params}`,
{ headers: { 'User-Agent': 'my-app-name/1.0' } },
);
const items = await r.json();
console.log(items[0].title, items[0].watched);
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests
r = requests.get(
'https://data.simkl.in/discover/trending/movies/today_100.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0',
params={
'client_id': 'YOUR_CLIENT_ID',
'app-name': 'my-app-name',
'app-version': '1.0',
},
headers={'User-Agent': 'my-app-name/1.0'},
)
items = r.json()
print(items[0]['title'], items[0]['watched'])
```
```go Go theme={"theme":{"light":"github-light","dark":"vesper"}}
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type Item struct {
Title string `json:"title"`
Rank int `json:"rank"`
Watched int `json:"watched"`
}
func main() {
req, _ := http.NewRequest("GET",
"https://data.simkl.in/discover/trending/movies/today_100.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0", nil)
q := req.URL.Query()
q.Set("client_id", "YOUR_CLIENT_ID")
q.Set("app-name", "my-app-name")
q.Set("app-version", "1.0")
req.URL.RawQuery = q.Encode()
req.Header.Set("User-Agent", "my-app-name/1.0")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var items []Item
json.NewDecoder(resp.Body).Decode(&items)
fmt.Println(items[0].Title, items[0].Watched)
}
```
**Always send a `User-Agent`** with your app name and version (e.g. `myapp/1.0`) to avoid accidental blocking.
## What's in each item
Per-title arrays (`movies`, `tv`, `anime`) all share the same base shape, with a few type-specific fields.
```json Sample item theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "The Drama",
"url": "/movie/2533163/the-drama",
"poster": "19/19702715dfee84bd7c",
"fanart": "19/19840547ea18093f08",
"ids": {
"simkl_id": 2533163,
"slug": "the-drama",
"imdb": "tt33071426",
"tmdb": "1148540"
},
"release_date": "03/26/2026",
"rank": 3311,
"drop_rate": "1.6%",
"watched": 96,
"plan_to_watch": 820,
"ratings": {
"simkl": { "rating": 7.37, "votes": 136 },
"imdb": { "rating": 7.4, "votes": 42928 }
},
"country": "us",
"runtime": "1h 45m",
"status": "ended",
"genres": ["Action", "Comedy", "Drama", "Romance"],
"trailer": "0ZDzsH3XGFA",
"overview": "A happily engaged couple is put to the test...",
"metadata": "March 26, 2026 • Budget $28M • Box office $121M",
"dvd_date": "05/05/2026",
"theater": "03/26/2026"
}
```
### Field reference
Display title.
Path on simkl.com (e.g. `/movie/2533163/the-drama`). Prepend `https://simkl.com` to deep-link.
Image path fragment. Combine with the prefixes in [Image conventions](/conventions/images) — for example `https://simkl.in/posters/{poster}_m.webp`. When `null`, fall back to `https://simkl.in/poster_no_pic.png` (see [fallbacks](/conventions/images#fallback-when-images-are-missing)).
Image path fragment for fanart. Same prefixing rules as `poster`. When `null`, hide the fanart element — there's no dedicated fanart placeholder.
External and Simkl IDs. Always carries `simkl_id` + `slug`. `tmdb` is near-universal; `imdb` appears on TV / movies, `mal` / `anidb` / `anilist` / `kitsu` on anime. Additional third-party slug variants (`letterslug`, `traktmslug`, `tvdbslug`, `trakttvslug`, `mdlslug`, …) may appear on items with those platform links — the response is permissive.
Simkl ID — primary key for `/movies/{id}`, `/tv/{id}`, `/anime/{id}`.URL-safe slug.IMDB ID, e.g. `tt0944947`.TMDB ID.TVDB ID (TV / anime).MyAnimeList ID (anime).AniDB ID (anime).AniList ID (anime).Kitsu ID (anime).
Simkl service rank for this media type.
Number of users who watched this title in the timeframe.
Number of users with this title on their Plan-to-Watch watchlist.
Percentage of users who started and dropped, formatted as a string (e.g. `"1.6%"`).
Aggregate ratings — Simkl and any external sources available for this title (IMDB for movies/TV, MAL for anime, etc.). Each entry is `{ rating: number, votes: number }`.
Original release date in `MM/DD/YYYY` format.
ISO-style country code (e.g. `us`, `jp`).
Human-readable duration (e.g. `1h 45m`, `25m`).
`ended`, `ongoing`, `tba`, or similar.
Array of genre tags.
YouTube video ID.
Synopsis.
Pre-formatted human-readable summary line (release year, budget, box office, network, etc.).
**Movies only.** DVD release date (`MM/DD/YYYY`).
**Movies only.** Theatrical release date (`MM/DD/YYYY`).
**Anime only.** One of `tv`, `movie`, `special`, `ova`, `ona`, `music video`.
**TV / anime.** Episode count.
**TV / anime.** Broadcasting network.
# About TV
Source: https://api.simkl.org/api-reference/tv
Look up TV shows, browse what's airing or premiering, and pull episode lists.
The TV API returns metadata about TV shows in Simkl's catalog. None of these endpoints require an OAuth `token` — a `client_id` is enough.
The `status` field on a show can be `ended`, `tba`, or `airing`.
## Look up a show
Item-level lookups for when you already know which show you want. Both are Cloudflare-cached by Simkl ID — see [Rate limits → Parallel requests](/resources/rate-limits#parallel-requests--when-allowed).
`GET /tv/{id}` — full record (overview, network, runtime, country, certification, genres, status, first/last-aired dates, total episodes, airs schedule, ratings, posters, fanart, trailers, external IDs, user recommendations).
`GET /tv/episodes/{id}` — every season + episode for a show, with `aired` flags and airdates.
If you only have an external ID (IMDb, TMDB, TVDB), resolve it to a Simkl ID via [`GET /redirect`](/api-reference/redirect) first — header-only, Cloudflare-cached, and the canonical resolver.
## Browse & discover
Find shows by what's on now, what's coming, what's top-rated, or by genre / country / network / year.
`GET /tv/airing` — what's airing right now.
`GET /tv/premieres/{param}` — upcoming season and series premieres.
`GET /tv/best/{filter}` — top-rated shows by filter (year, all-time, etc.).
`GET /tv/genres/...` — browse by genre, country, network, year.
## Pre-built data files (no per-user cost)
Two static JSON resources on the CDN cover the most common "what's hot / what's airing" surfaces without paying any per-user request budget. Send the standard URL parameters and User-Agent; no `Authorization` token needed.
`https://data.simkl.in/trending/tv_today.json` + week / month variants, plus Top-100 and Top-500 versions. Refreshed daily. Drives "Most Watched" / "Trending Now" surfaces without polling.
`https://data.simkl.in/calendar/tv.json` + per-month archives at `/calendar/{YEAR}/{MONTH}/tv.json`. Updated every 6 hours. Far cheaper than polling `/tv/airing`.
# About Users
Source: https://api.simkl.org/api-reference/users
Read public user data, fetch recently-watched art, and update user settings.
Public user data is available via `GET` methods without an OAuth or PIN token — only a `client_id` is required. Endpoints that change user data (settings, stats writes) require an authenticated `token`.
| Endpoint | What it does |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /users/recently-watched-background/{user_id}` | Returns the user's most-recently-watched show or movie with its title, large poster, and large fanart. Useful for wallpaper apps or "now watching" widgets. |
| `POST /users/{user_id}/stats` | Returns aggregate watch statistics for the user. |
| `POST /users/settings` | Update the authenticated user's settings. |
Cache images on the device using the image URL as the cache key. Image URLs are immutable — never re-download the same URL.
`GET /users/recently-watched-background/{user_id}`
`POST /users/{user_id}/stats`
`POST /users/settings`
# API rules
Source: https://api.simkl.org/api-rules
Read this before you ship. The short list of what's required, what's free, and when you need a commercial license.
### Help us keep the Simkl API at 100% uptime — optimise your app's requests
The limits below are generous and most apps never hit them. The ones that do are almost always parallelizing calls to endpoints that should be sequential, or polling [`/sync/all-items`](/api-reference/simkl/get-all-items) without gating behind [`/sync/activities`](/api-reference/simkl/get-activities). The patterns on this page keep your app fast **and** keep the API healthy for every other developer building on Simkl.
These are the rules of the road for using the Simkl API. They're short, they're real, and they exist for good reasons. Read once, agree, and ship.
By using the Simkl API, you also agree to the full [Simkl Terms](https://simkl.com/about/policies/terms/).
**Don't get your `client_id` suspended.** The Simkl API caps every app at **10 GET/sec and 1 POST/sec** per `client_id` and per user token — generous, and most apps never hit them. The ones that do are almost always either parallelizing calls to uncached endpoints, or polling [`GET /sync/all-items`](/api-reference/simkl/get-all-items) on a timer without first checking [`GET /sync/activities`](/api-reference/simkl/get-activities) for changes. Apps that sustain overage get their `client_id` suspended **without warning, no appeal** — we see the traffic pattern and turn the key off.
See [**Rate limits**](/resources/rate-limits) for the full caps, when parallel requests are allowed (only on Cloudflare-cached endpoints), and the sequential-by-default pattern. The two-phase sync model in [Rule 7 → Sync incrementally](#7-be-a-good-api-citizen) shows the correct loop in code.
## 1. Link back to Simkl
Wherever Simkl data appears in your app, link back to the Simkl page for **that specific item** — not just a generic homepage link. Better for users (they can dive into ratings, episodes, related titles, reviews), better for Simkl, and trivial to implement.
`https://simkl.com/movies/:simkl_id/:slug` — [example](https://simkl.com/movies/2059585/superman)
`https://simkl.com/tv/:simkl_id/:slug` — [example](https://simkl.com/tv/1076658/the-witcher)
`https://simkl.com/anime/:simkl_id/:slug` — [example](https://simkl.com/anime/38636/one-piece)
Both `ids.simkl` and `ids.slug` are returned on every [standard media object](/conventions/standard-media-objects), so the slug is **free** — you already have it in the response you just parsed.
**Always include the slug when you have it.** It's technically optional — bare numeric URLs like `simkl.com/movies/53536` still work — but Simkl has to run a database lookup and `301`-redirect to the slugged URL, costing your user an extra round-trip and Simkl an extra search query. The slug is right there in `ids.slug`; pass it through and everyone wins.
**The `simkl` ID is the only stable identifier — slugs are not unique.** Multiple titles can share the same slug (e.g. three different *Superman* movies live at `/movies/2059585/superman`, `/movies/260740/superman`, `/movies/951252/superman`). Always build links with the numeric ID first; the slug is purely a human-readable URL hint. Never key your cache or do a lookup by slug alone.
```js title="build-simkl-url.js" theme={"theme":{"light":"github-light","dark":"vesper"}}
function simklUrl(item) {
const section = { movie: 'movies', anime: 'anime' }[item.type] || 'tv';
return 'https://simkl.com/' + section + '/' +
(item.ids.simkl || item.ids.simkl_id) + '/' +
(item.ids.slug || '');
}
simklUrl(movieDetails); // → https://simkl.com/movies/1306562/project-hail-mary
simklUrl(tvDetails); // → https://simkl.com/tv/967226/the-boys
simklUrl(animeDetails); // → https://simkl.com/anime/1885096/tongari-boushi-no-atelier
// No slug? URL ends with /:
simklUrl({ type: 'movie', ids: { simkl: 53536 } });
// → https://simkl.com/movies/53536/
```
Notes on the moving parts:
* TV detail responses set `item.type = "show"` even though the URL section is `/tv/`. The map deliberately only lists `movie` and `anime`; anything else (including `"show"` and the empty `type` returned by `/search/*` and `/{movies|tv|anime}/trending`) falls through to `/tv/` — Simkl will 301 to the correct section if needed, but you should set `item.type` yourself when you already know it (e.g. on `/movies/trending` results) to skip that hop.
* The id key is either `ids.simkl` (detail endpoints, `/sync/*` responses, request bodies) or `ids.simkl_id` (`/search/*`, `/trending`, `/calendar`, `/genres`, `/best`, `/premieres`, episode-level detail). The same integer; the key name differs by endpoint family — `simkl || simkl_id` covers both. See [Standard media objects → Supported ID keys](/conventions/standard-media-objects#supported-id-keys).
**Don't have a Simkl ID yet?** If all you have is an external ID — IMDB, TMDB, TVDB, MAL, AniDB, etc. — drop a [`/redirect`](/api-reference/redirect) URL **straight into your ``**. No request, no parsing, no JSON — the browser follows the `301` for you and the user lands on the canonical Simkl page.
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
View on Simkl
```
[Click to try it](https://api.simkl.com/redirect?to=simkl\&imdb=tt0944947\&client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) → lands on [`https://simkl.com/tv/17465/game-of-thrones`](https://simkl.com/tv/17465/game-of-thrones).
Six placements that count as proper attribution:
* **Detail page** — a "View on Simkl" button or `Source: Simkl` line under the cover art.
* **List / grid rows** — make the cover or title clickable directly to the item's Simkl page.
* **Hover cards / tooltips** — include a "More on Simkl →" link inside the popover.
* **Footer / About** — a one-time `Movie, TV and anime data from Simkl` line.
* **Trending sections** — the title must say "Simkl" (see [rule 6](#6-attribute-simkl-in-trending-ui)) **and** items should deep-link to their Simkl page.
* **Empty / loading states** — "Powered by Simkl" line linking to [simkl.com](https://simkl.com) when no specific item is in view yet.
For a one-line text widget or a watch ticker where you only have room for a single link, a visible link to [simkl.com](https://simkl.com) is the minimum.
* A `simkl.com` link buried in your privacy policy with no visible UI attribution.
* An unbranded `More info` that doesn't say "Simkl".
* Fetching Simkl data into your own database and dropping the link entirely.
**These are just illustrative samples — not visual requirements.** Match the colours, shape, type, and density to your own design system. The only attribution rules are the substantive ones above: Simkl is named, the brand mark is visible, and the link goes to the per-item page with the slug.
Two brand-mark assets are available — the **colored PNG** for full-colour brand badges and the **monochrome SVG** (transparent "S" cutout) for icons that recolour with their parent via CSS `filter`:
| Asset | URL |
| -------------- | ---------------------------------------------------------- |
| Colored PNG | `https://us.simkl.in/img_favicon/v2/favicon-192x192.png` |
| Monochrome SVG | `https://us.simkl.in/img_favicon/v2/safari-pinned-tab.svg` |
```html Minimal rating pill (PNG) theme={"theme":{"light":"github-light","dark":"vesper"}}
★7.8
```
```html Rating badge (PNG) theme={"theme":{"light":"github-light","dark":"vesper"}}
★7.8on Simkl→
```
```html Inline "More on Simkl" (PNG) theme={"theme":{"light":"github-light","dark":"vesper"}}
More on Simkl →
```
```html "Powered by Simkl" pill (SVG) theme={"theme":{"light":"github-light","dark":"vesper"}}
Powered by Simkl
```
Not sure how to implement this for your specific app? [DM us on Discord](https://discord.gg/MJsWNE4) or visit [support.simkl.com](https://support.simkl.com) — we'll help you find the right placement.
## 2. Tracker apps must offer Simkl sync
If your app or service is a Movie / TV / Anime / Manga **tracker** that already syncs with another tracker, you may use the Simkl API only if you also offer **Simkl login and sync** alongside.
In other words: the catalog and discovery endpoints are not a free metadata source for a competing tracker that doesn't integrate Simkl. If you're integrating Simkl as one of multiple supported services in a media app, you're good. If you're using Simkl's catalog without offering Simkl sync, [contact us first](/support).
## 3. Use TVDB / TMDB for raw metadata
If you need a generic metadata host, please use [TVDB](https://thetvdb.com) and [TMDB](https://themoviedb.org) APIs from their original sources. Simkl is built for **tracking and discovery**, not as a CDN for the world's metadata.
## 4. Keep your `client_secret` secret
`client_secret` is a credential — treat it like a password. Never commit it to a public repo, and never embed it in code that ships to a user's device (mobile apps, single-page apps, browser extensions, desktop binaries). On a server, store it in an environment variable; in CI, store it as an encrypted secret — every CI provider supports this natively. A leaked secret lets anyone authenticate as your app, eats into your rate-limit quota, and forces you to rotate from your [app settings](https://simkl.com/settings/developer/) and re-auth every user.
`client_id` is **not** a secret — it's a public identifier that appears in every API URL and is fine to include in client-side code. The thing to protect is `client_secret`.
For client-side apps that can't keep a secret, pick the flow that matches your platform — both work without `client_secret`:
* **Mobile, single-page app, browser extension, desktop binary** — use [Public PKCE](/api-reference/oauth-pkce). Same browser-based UX as the standard OAuth flow, with a one-time `code_verifier` instead of a shipped secret.
* **TV, console, smartwatch, CLI tool, media-server plugin** — use the [PIN flow](/api-reference/pin). Show a 5-character code, the user enters it on their phone.
## 5. Free for non-commercial use, and under \$150 / month
The Simkl API is **free** for:
* Non-commercial apps and personal projects.
* Commercial apps and services that generate **less than \$150 / month in revenue**.
If your app generates **\$150 / month or more**, you must obtain a **commercial license** before continuing to use the API. [Reach out via Discord](https://discord.gg/MJsWNE4) to discuss terms.
Running the API at scale costs us real money in DNS, routing, health checks, CDN, server hosting, failover, and ops. Commercial fees cover those costs proportional to your usage.
## 6. Attribute Simkl in trending UI
If you display [Trending data](/api-reference/trending), you must use a section title that names Simkl — for example, `Simkl Trending Today`, `Trending on Simkl This Week`, `Simkl Trending Movies`. Full rules and examples on the [Trending page](/api-reference/trending#attribution-required).
## 7. Be a good API citizen
These are the habits well-built apps share. They keep your app fast, keep your users happy, and keep your traffic well clear of the rate-limit and overage patterns that trip the suspension warning at the top of this page.
Most metadata never changes. Cache by URL on the device — minutes for user data, hours for catalogs, **forever** for [images](/conventions/images).
The [Trending](/api-reference/trending) and [Calendar](/api-reference/calendar) JSON files don't count against your `client_id` quota and are cached at the edge. Always prefer them over per-user catalog calls.
**Never call watchlist endpoints without first checking [`/sync/activities`](/api-reference/simkl/get-activities).** And never run unconditional background polling timers without active user interaction.
Use a two-phase pattern:
1. **Initial sync (one time per user)** — pull the full library, no `date_from` on this pass.
* **Multi-type apps:** call `/sync/all-items/shows`, `/sync/all-items/movies`, `/sync/all-items/anime` **sequentially** (not in parallel — back-to-back massive payloads spike your CPU and ours).
* **Single-type apps** (anime-only, movie-only, TV-only): call only the one type you care about.
2. **Continuous sync (every poll after that)** — call `/sync/activities` first; if every timestamp matches what you stored locally, skip. Otherwise:
* **Multi-type apps:** `/sync/all-items?date_from=YOUR_SAVED_TIMESTAMP` — pulls deltas across all types and statuses in one response.
* **Single-type apps:** `/sync/all-items/{your_type}?date_from=YOUR_SAVED_TIMESTAMP` — same delta semantics, scoped to one type so you don't transfer data you'll discard.
Save the new `activities.all` timestamp for next time.
Triggers — sync **on user-visible events**, not timers:
* **Mobile / TV apps** — app start, wake-from-background, pull-to-refresh. Throttle to once per 15–30 min.
* **Media servers (Plex, Kodi, Jellyfin)** — library-scan-completed events, or when a playback session ends.
Full walkthrough with code: [Sync guide](/guides/sync).
`POST /sync/history`, `POST /sync/history/remove`, `POST /sync/ratings`, and `POST /sync/add-to-list` all accept arrays. Send 50 items in one call, not 50 calls.
Identify your app: `myapp/1.0`. It helps us tell legitimate clients apart from bad actors and keeps you out of any accidental blocks.
On `429`, `500`, `502`, `503`: wait, retry, double the wait, cap at 60s, give up after 5 attempts. See [Errors](/conventions/errors).
## More
The full set of limit signals and how to handle them.
Every status code Simkl returns and what to do about it.
Discord, email, GitHub — pick your favorite.
# Authentication
Source: https://api.simkl.org/authentication
Three flows — OAuth 2.0 for server-side web apps, Public PKCE for mobile and SPAs, PIN for TVs and CLIs. Pick one and you're 5 minutes from your first authenticated call.
Every Simkl API call that touches a user's data needs a per-user `access_token`. Tokens are long-lived — the success response advertises `expires_in: 157680000` (5 years), and in practice tokens remain valid until the user revokes your app from [Connected Apps settings](https://simkl.com/settings/connected-apps/). Simkl gives you three ways to obtain a token. They're all variants of OAuth 2.0 from the user's perspective, but the integration story is very different. Pick the flow that matches the device your app runs on.
For **server-side web apps** that can keep a `client_secret`. The user logs in via their browser; your backend exchanges the `code` for a token. \~5 seconds end-to-end.
For **mobile, SPA, browser extensions, desktop binaries** — any client where you can't safely embed `client_secret`. Same browser-based UX, no secret required.
For **TVs, consoles, watches, CLIs, and media-server plugins** — anywhere typing a URL is hard. Show a 5-character code; the user enters it on their phone.
## At a glance
| | OAuth 2.0 | Public PKCE | PIN |
| ------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------- |
| **Best for** | Server-side web apps | Mobile, SPA, browser extensions, desktop binaries | TVs, consoles, watches, CLIs, plugins |
| **Time to token** | \~5 seconds | \~5 seconds | 30 seconds – 2 minutes |
| **Needs `client_secret`** | Yes | **No** — uses `code_verifier` + `code_challenge` | No |
| **Needs `redirect_uri`** | Yes (pre-registered, byte-for-byte) | Yes, OR omit if no redirect URI is registered (consent completes on simkl.com) | No |
## Get an API key first
All three flows require a `client_id`. [Create an app](https://simkl.com/settings/developer/) — free, no approval required. Confidential clients also receive a `client_secret`; public clients can ignore it.
**Never embed `client_secret` in client-side code.** Mobile apps, single-page apps, browser extensions, and desktop binaries should use [Public PKCE](/api-reference/oauth-pkce) or the [PIN flow](/api-reference/pin) — both work without a secret.
## Full walkthrough
The complete reference — platform recommendations, multi-language code samples, common pitfalls, token lifecycle — lives in the API Reference:
Per-platform recommendations (iOS, Android, web, desktop, TV, headless), step-by-step OAuth and PIN walkthroughs with curl/Swift/Kotlin/Node/Python samples, common pitfalls, and token lifecycle in one page.
## Endpoint reference
`GET /oauth/authorize` and `POST /oauth/token`.
PKCE flow ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)) with per-platform recipes.
`GET /oauth/pin` and `GET /oauth/pin/{USER_CODE}`.
## Before you reach for an OAuth library
**Both content-types and both credential locations work.** Simkl's `POST /oauth/token` accepts:
* `Content-Type: application/x-www-form-urlencoded` (the RFC 6749 §3.2 default) **or** `Content-Type: application/json` — pick whichever your HTTP client prefers
* Client credentials in the request body (`client_id` + `client_secret` parameters) **or** in the `Authorization: Basic` header (RFC 6749 §2.3.1) — both paths are honored
That means **off-the-shelf OAuth libraries work out-of-the-box** with no custom encoding or auth-method config. The two equivalent ways to call the token endpoint:
```bash form-encoded (RFC 6749 §3.2 default) theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "User-Agent: my-app-name/1.0" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "code=AUTHORIZATION_CODE" \
--data-urlencode "redirect_uri=YOUR_REDIRECT_URI" \
--data-urlencode "grant_type=authorization_code"
```
```bash JSON body theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST https://api.simkl.com/oauth/token \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "YOUR_REDIRECT_URI",
"grant_type": "authorization_code"
}'
```
For library-specific examples (Python, Node, Java, Go, PHP), see [OAuth client libraries](/api-reference/oauth-libraries) — most are zero-config.
# Changelog
Source: https://api.simkl.org/changelog
What's new across the Simkl API and these docs.
**[Discord](https://discord.gg/MJsWNE4)** is where most API announcements and developer chat happen — join us for new endpoints, breaking changes, and integration help in real time.
Prefer a feed reader? Subscribe to the [**RSS feed**](/rss.xml) for everything below, delivered to your inbox.
For broader Simkl product news — apps, features, releases — follow [**@Simkl**](https://twitter.com/Simkl) on X.
Use the **filter** in the right rail to narrow this changelog by tag.
New feature
**Rewatches via `?allow_rewatch=yes`**
`POST /sync/history` and `GET /sync/all-items` now support tracking rewatches as separate sessions when the caller opts in via `?allow_rewatch=yes`. Available to **Simkl Pro and VIP** subscribers.
* **On `POST /sync/history`:** a watch event on an already-Completed item creates a new rewatch session instead of being a no-op. The server auto-detects rewatches (treats a write as one when the item is already Completed and the add was a no-op, or when a payload includes episodes and none of them were new), or you can force it with `is_rewatch: true` on the item.
* **On `GET /sync/all-items`:** any item (movie, show, or anime) with saved rewatch sessions appears multiple times in the response — its normal entry, plus one extra entry per rewatch session. The extra entries carry `is_rewatch: true`, `rewatch_id`, `rewatch_status` (`active` / `completed` / `closed`), `last_watched_at`, and `watched_episodes_count`. Without the flag, every item appears once and none of those fields are emitted.
* **Per-item write fields** (all optional): `is_rewatch`, `rewatch_id` (resume an existing session), `rewatch_status`, `last_watched_at`.
* **Limits:** up to 50 rewatches per item (movie, show, or anime); any two watch events on the same item (movie or episode) must be at least 2 days apart — it's a rewatch, not a rewind 😄.
Full walkthrough in the new [**Rewatches guide**](/guides/rewatches) — session lifecycle, episode tracking, reading sessions back, and ready-made code for simkl.com-style UI patterns. Quick mention also in the [Sync guide → Record a rewatch](/guides/sync#record-a-rewatch-simkl-pro-vip-only) and [Mark as watched → Record a rewatch](/guides/mark-as-watched#record-a-rewatch-simkl-pro-vip) accordions.
Docs
**Docs revamp**
[Apiary](https://simkl.docs.apiary.io/) is retired (Oracle is shutting it down). The docs now live at [api.simkl.org](https://api.simkl.org) with a hand-curated OpenAPI 3.1 spec, tested examples on every endpoint, and an interactive playground on the API reference.
* New flat [**Errors and status codes**](/conventions/errors) page grouped by HTTP status with cause / fix / example for each.
* Default three-column layout site-wide (left sidebar / content / right-rail TOC) so every page has navigable section anchors.
* Eyebrow group tags above every page title for quick orientation.
Breaking
**`app-name` and `app-version` are now required**
Every API request must now include `app-name` and `app-version` URL parameters alongside `client_id`, plus a descriptive `User-Agent` header. This lets us help debug issues faster and prevents accidental blocks.
```
/endpoint?client_id=CLIENT_ID&app-name=my-app-name&app-version=1.0
```
See the updated [Quickstart](/quickstart) and [Headers and conventions](/conventions/headers).
Docs
**Renamed in the docs: "List" → "Watchlist"**
Simkl's website introduced **Custom Lists** — user-created collections of titles. To avoid confusion with the existing 5-status bucket system (Watching, Plan to Watch, Hold, Dropped, Completed), the API docs now refer to that bucket system as the **Watchlist** instead of "List".
**Nothing changed at the API level.** The endpoint URL `/sync/add-to-list` is unchanged, request/response shapes are unchanged. Only the docs use the term "Watchlist" now — e.g., the endpoint is referred to as **"Add to Watchlist"** in the sidebar and prose.
A dedicated Custom Lists API may ship in a future release.
New feature
**New: Pre-built trending JSON files**
We've published trending data as static JSON on `data.simkl.in` — no API key required (User-Agent and attribution still required). Useful for "What's hot right now" surfaces without paying any per-user request budget.
* **Top 100** and **Top 500** lists
* **Today**, **This Week**, and **This Month** timeframes
* **Combined**, **Movies**, **TV Shows**, and **Anime** categories
* **Latest popular DVD releases** list
* Full title info with multiple IDs
See the full file index in [Trending data files](/api-reference/trending).
New feature
**Manual "fire-and-forget" tracking**
New endpoint: [`POST /scrobble/checkin`](/api-reference/simkl/scrobble-checkin). Send one request when the user starts watching; Simkl handles the "watching now" bar, runtime tracking, and auto-completion when runtime elapses. One call, then nothing else — useful when you don't have real player events to drive [`/start`](/api-reference/simkl/scrobble-start) / [`/pause`](/api-reference/simkl/scrobble-pause) / [`/stop`](/api-reference/simkl/scrobble-stop).
| | `/scrobble/checkin` (manual) | `/scrobble/start` / `/pause` / `/stop` (real-time) |
| ----------------- | ----------------------------------- | -------------------------------------------------- |
| **Purpose** | Simple "watching now" status | Active player tracking |
| **When to call** | Once, when the user starts watching | On real player events (start, pause, stop, resume) |
| **Auto-complete** | Yes (marks watched on expiry) | No — requires explicit `/stop` |
| **`progress`** | Optional | Required |
Use cases: cinema apps, live-TV trackers, social "watching now" status updates. See [Scrobble guide](/guides/scrobble).
Improvement
**PIN poll response — two distinct shapes**
The `/oauth/pin/{USER_CODE}` polling response now returns one of two explicit JSON shapes (instead of a single shape with mixed semantics): `{ "result": "KO", "message": "Authorization pending" }` while the user hasn't entered the code yet, and `{ "result": "OK", "access_token": "..." }` once they approve. Simpler client-side state machine — branch on `result`.
See [PIN authentication](/api-reference/pin) for the response schemas.
Major launch
**Major: real-time scrobble + cross-device playback**
Two big additions for media-player integrations:
* **Scrobble API** — [`POST /scrobble/start`](/api-reference/simkl/scrobble-start), [`/pause`](/api-reference/simkl/scrobble-pause), and [`/stop`](/api-reference/simkl/scrobble-stop) for real-time playback tracking with progress percentages. See the [Scrobble guide](/guides/scrobble).
* **Playback API** — [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions) and [`DELETE /sync/playback/{id}`](/api-reference/simkl/delete-playback) to read and clean up paused sessions across devices.
* [`GET /sync/activities`](/api-reference/simkl/get-activities) now includes a `playback` timestamp per type, so you know when to refetch playback state.
* New web UI: [Playback progress manager](https://simkl.com/my/history/playback-progress-manager/) — users can now manage their saved sessions in-product.
Use case: a user pauses an episode at 40% on the living-room TV, switches to their iPad in the bedroom, and the iPad app picks up exactly where they left off.
Note: only **one** playback is stored per show/movie. Starting a new episode replaces any previous paused playback for that title.
New feature
**Monthly archive endpoints**
The [Calendar JSON](/guides/calendar) now includes per-month archives for the last 12 months at the URL pattern `https://data.simkl.in/calendar/{YEAR}/{MONTH}/{tv|anime|movie_release}.json`. The current month regenerates every 6 hours; previous months once a day.
Improvement
**`runtime` (minutes) on shows and movies**
`/sync/all-items` responses now include a `runtime` integer field on show and movie items — minutes per episode for shows, total runtime for movies. Useful for "time to finish", watch-time stats, and pacing surfaces.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"runtime": 40
}
```
Improvement
**`simkl_type` and `anime_type` in `/sync/history` response**
The per-item `response` object inside `added.statuses` now includes `simkl_type` (`movie` / `tv` / `anime`) and `anime_type` (`tv` / `special` / `ova` / `movie` / `music video` / `ona`).
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
"response": {
"status": "completed",
"simkl_type": "anime",
"anime_type": "movie"
}
```
Useful when you're sending titles by TMDB ID and need to know how Simkl classified the item — e.g., a TMDB movie that Simkl resolved as an anime movie. Also handy for clients that store `simkl_type` locally so they can later reconcile deletions across types.
Improvement
**Complete a show in one call**
Two additions to `POST /sync/history`:
* **`status: "completed"`** — pass on a show item to set the watchlist status directly. If the show is still airing, Simkl keeps it in **Watching** instead and reflects the resolved status in the response.
* **`use_tvdb_anime_seasons: true`** — pair with `status: "completed"` on anime to auto-mark every season watched, using TVDB/TMDB seasonal numbering. Lets clients that index against TVDB skip the AniDB season-mapping step.
The response now returns a `statuses` array showing the resolved per-item status:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"added": {
"movies": 0,
"shows": 1,
"episodes": 0,
"statuses": [
{
"request": { "ids": { "simkl": 39687 }, "status": "completed" },
"response": { "status": "watching" }
}
]
}
}
```
See [`POST /sync/history`](/api-reference/simkl/add-to-history).
# CORS
Source: https://api.simkl.org/conventions/cors
Cross-origin request support on api.simkl.com.
The Simkl API supports **Cross-Origin Resource Sharing (CORS)** on the `api.simkl.com` domain — you can call it directly from a browser using `fetch` or `XMLHttpRequest`.
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const res = await fetch(
'https://api.simkl.com/tv/17465?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0',
{
headers: {
'Content-Type': 'application/json',
'User-Agent': 'my-app-name/1.0',
},
},
);
```
**Don't ship `client_secret` in browser code.** If you need authenticated calls from a browser, use the [PIN flow](/api-reference/auth#how-the-pin-flow-works) (no secret) or proxy calls through your own server.
## Useful reading
* [CORS W3C working draft](http://www.w3.org/TR/cors)
* [HTML5 Security: Cross-Origin Request Security](http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity)
# Dates and timezones
Source: https://api.simkl.org/conventions/dates
How Simkl returns dates and what to do in your app.
All API dates are returned in **ISO 8601** format with the **`Z` (UTC / GMT)** timezone marker:
```
2015-03-15T15:30:11Z
```
Some legacy endpoints still return dates in `GMT-05:00` (New York) — this is being phased out, but for now treat the timezone field as authoritative rather than assuming UTC.
Convert to the user's local timezone in your app — don't assume the server's locale.
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
const utc = new Date('2015-03-15T15:30:11Z');
const local = utc.toLocaleString(); // user's locale
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
from datetime import datetime, timezone
utc = datetime.fromisoformat('2015-03-15T15:30:11+00:00')
local = utc.astimezone() # system local timezone
```
## Per-endpoint format matrix
Different endpoints use different date formats depending on whether
the field is a user activity timestamp, a broadcast schedule, or a
catalog release date. Use the per-field reference below to know what
to parse for each one.
**The four formats you'll meet:**
| Format | Example | When |
| --------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`ISO_UTC_Z`** | `2026-05-13T16:08:00Z` | Default for user-generated timestamps (activities, watchlist, playback, ratings, profile). Convert to the user's profile timezone for display. |
| **`ISO_WITH_OFFSET`** | `2010-10-31T21:00:00-05:00` | Broadcast schedules — the offset is the **originating network's** timezone (US shows are `-05:00`, anime are `+09:00`). Use it as authoritative; don't assume UTC. |
| **`DATE_ONLY`** | `2010-07-15` | Catalog release dates (year-month-day, no time). |
| **`YEAR_ONLY`** | `2010` | Integer year-of-release on every catalog item. |
Plus the `1970-01-01T00:00:01Z` placeholder for *"watched, date unknown"* — see [the section below](#very-long-time-ago-placeholder).
### Endpoint → field → format
| Endpoint | Field(s) | Format | Notes |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------- |
| [`GET /movies/{id}`](/api-reference/simkl/get-movie) | `released`, `release_dates[*].results[*].release_date` | `DATE_ONLY` | Catalog release; no time component. |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show) | `first_aired`, `last_aired` | `ISO_UTC_Z` | Series-level air-window markers. |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | `first_aired`, `last_aired` | `ISO_UTC_Z` | Same as TV. |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `[*].date` | `ISO_WITH_OFFSET` | Per-episode air time in the **network's local timezone** (e.g. `-05:00` for US shows). |
| [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | `[*].date` | `ISO_WITH_OFFSET` | Per-episode air time in **`+09:00` (Asia/Tokyo)** for the canonical Japanese broadcast. |
| [`GET /tv/airing`](/api-reference/simkl/get-tv-airing) | `[*].date` | `ISO_WITH_OFFSET` | Originating network's timezone. |
| [`GET /anime/airing`](/api-reference/simkl/get-anime-airing) | `[*].date` | `ISO_WITH_OFFSET` | Asia/Tokyo for Japan-originated anime. |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | every `*_at` field (`all`, `tv_shows.completed`, `anime.dropped`, etc. — 27 fields total) | `ISO_UTC_Z` | All UTC; convert to profile timezone for display. |
| [`GET /sync/all-items/{type}`](/api-reference/simkl/get-all-items) | `[*].added_to_watchlist_at`, `[*].last_watched_at`, `[*].user_rated_at` | `ISO_UTC_Z` | All user-activity timestamps in UTC. |
| [`GET /sync/playback/{type}`](/api-reference/simkl/get-playback-sessions) | `[*].paused_at` | `ISO_UTC_Z` | Pause-event timestamp in UTC. |
| [`POST /users/settings`](/api-reference/simkl/get-user-settings) | `user.joined_at` | `ISO_UTC_Z` | Account creation date. |
| [`GET /calendar/{type}.json`](/api-reference/calendar) *(CDN)* | `[*].date` | `ISO_WITH_OFFSET` | Episode-level air time. |
| [`GET /calendar/{type}.json`](/api-reference/calendar) *(CDN)* | `[*].release_date` | `DATE_ONLY` | Catalog release date alongside the air-time entry. |
| [`GET /calendar/{year}/{month}/{type}.json`](/api-reference/calendar) *(CDN)* | same as rolling calendar | mixed | Same shape, different month window. |
| Every catalog endpoint | `year` | `YEAR_ONLY` (integer) | Release year as an int — convenient for filtering. |
**`ISO_WITH_OFFSET` is authoritative — don't strip it.** The
broadcast `date` field on episode/airing/calendar endpoints encodes
the originating network's timezone deliberately. Stripping the offset
and assuming UTC will silently shift airing schedules by hours (US
shows by 5, Japanese anime by 9). Always parse the timezone too.
**Missing dates** show up as `null` (Type 4 — "data not on file"),
not as the literal string `"null"` or epoch. See [Null and missing
values](/conventions/null-values) for the full nullability model.
The `1970-01-01T00:00:01Z` placeholder is **a different concept** —
it explicitly means "the user watched this but doesn't remember when",
not "we don't know" — see [the dedicated section
below](#very-long-time-ago-placeholder).
## User timezone preference
Each Simkl member has a **configurable timezone** on their profile, set at [simkl.com/settings/](https://simkl.com/settings/) (with the date and time format alongside it). Third-party apps can read the user's timezone via [`POST /users/settings`](/api-reference/simkl/get-user-settings) — it's returned as the `account.timezone` field, an IANA timezone name (e.g. `"America/New_York"`, `"Europe/Madrid"`, `"Asia/Tokyo"`):
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"user": { … },
"account": {
"id": 12345,
"timezone": "Europe/Madrid",
"type": "vip"
}
}
```
**Use the profile timezone for absolute timestamps when the user is signed in.** That's the single source of truth that keeps Simkl-rendered dates consistent across the user's phone, tablet, TV, and the simkl.com web UI.
| Scenario | What to use |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Authenticated user, absolute date (`"Watched on Apr 15 at 8 PM"`) | `account.timezone` from `POST /users/settings`. |
| Authenticated user, relative date (`"Watched 2 hours ago"`) | Device timezone is fine — relative deltas don't depend on absolute zone. |
| Unauthenticated / pre-login | Device timezone as a fallback. |
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
// Cache account.timezone after one call to /users/settings.
const PROFILE_TZ = 'America/New_York';
new Date('2026-05-13T16:08:00Z').toLocaleString('en-US', {
timeZone: PROFILE_TZ,
dateStyle: 'medium',
timeStyle: 'short',
});
// → "May 13, 2026, 12:08 PM"
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
from datetime import datetime
from zoneinfo import ZoneInfo
PROFILE_TZ = ZoneInfo('America/New_York') # from account.timezone
dt = datetime.fromisoformat('2026-05-13T16:08:00+00:00')
dt.astimezone(PROFILE_TZ).strftime('%Y-%m-%d %I:%M %p')
# → "2026-05-13 12:08 PM"
```
The user can change the setting at any time on simkl.com, but it's overwhelmingly **set-and-forget** in practice — don't re-fetch `POST /users/settings` on a timer or on every app launch / wake. Instead, gate the re-fetch on [`/sync/activities`](/api-reference/simkl/get-activities), which returns a `settings.all` timestamp alongside the per-list timestamps. The pattern matches every other Sync surface:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// On app launch / when you'd otherwise hit /users/settings:
const a = await getActivities();
if (a.settings.all !== local.lastSettingsSync) {
const settings = await fetchUsersSettings();
local.timezone = settings.account.timezone;
local.lastSettingsSync = a.settings.all;
}
```
That way most launches do **zero** extra calls (the activities check is the cheap "is anything new?" gate you're running for the rest of the sync loop anyway), and you only re-fetch the heavier settings payload on the rare occasion the user actually changed something.
The simkl.com settings UI also lets the user pick a **date format** (e.g. `MM/DD/YYYY`) and a **time format** (12-hour AM/PM vs 24-hour). Those preferences are **not currently exposed** by `POST /users/settings` — only `account.timezone` and `account.type` come back. Until they're added to the API, render dates and times using the device's locale conventions (e.g. `'en-US'` for AM/PM, `'en-GB'` for 24-hour) inside the user's profile timezone.
## "Very long time ago" placeholder
```
1970-01-01T00:00:01Z
```
Simkl uses this near-epoch timestamp as a **deliberate placeholder** meaning *"the user watched this but doesn't remember when"* — typically because they watched the title years ago and don't recall the precise date. It is **not** a NULL, a bug, or missing data. It can appear on any field that carries a watch timestamp: `watched_at`, `last_watched_at`, per-episode `watched_at` inside `seasons[].episodes[]`, and similar.
**On simkl.com,** the "When did you watch this?" date picker offers it as a smart-suggestion card labelled **"Very long time ago"** with the helper line *"I don't remember"*. That's the canonical user-facing label — clients should mirror it (translated to the user's locale).
### Writing the placeholder
When a user picks the "Very long time ago / I don't remember" option in your app's date picker, POST `1970-01-01T00:00:01Z` on the `watched_at` field. This works on every write endpoint that accepts watch timestamps — [`POST /sync/history`](/api-reference/simkl/add-to-history), [`POST /sync/watched`](/api-reference/simkl/get-watched), and so on:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"movies": [
{ "ids": { "simkl": 53536 }, "watched_at": "1970-01-01T00:00:01Z" }
]
}
```
Simkl stores this as a *"watched, date unknown"* entry and treats it consistently across the API and the simkl.com UI.
### Reading the placeholder
Any `watched_at` / `last_watched_at` value at or near `1970-01-01T00:00:01Z` is the placeholder. Detection rule: **any timestamp before `2000-01-01` is effectively the "date unknown" placeholder.** That guard handles both the canonical epoch+1 second value and any near-epoch variations from legacy data.
Render it in your UI as **"Very long time ago"** (matching simkl.com's label) or a localized equivalent — **never** as the literal `January 1, 1970`, which looks like a parsing error or a corrupt date to end users.
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
function formatWatchedAt(iso) {
const d = new Date(iso);
if (d.getFullYear() < 2000) return 'Very long time ago';
return d.toLocaleDateString();
}
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
from datetime import datetime, timezone
def format_watched_at(iso: str) -> str:
dt = datetime.fromisoformat(iso.replace('Z', '+00:00'))
if dt.year < 2000:
return 'Very long time ago'
return dt.astimezone().strftime('%Y-%m-%d')
```
### Date picker UX — mirror simkl.com
The simkl.com "When did you watch this?" date picker is the reference UX for date selection across the Simkl ecosystem. Apps that surface their own date pickers should mirror its three-tier structure:
**Common times** (quick-pick buttons):
* *Just now* — current timestamp
* *Today* — today at the current time
* *Yesterday* — yesterday at the current time
* *Last week* — 7 days ago at the current time
**Smart suggestions** (context-aware cards):
* *Episode Air Date* — pre-fills the episode's original broadcast date (label: "Original broadcast"). Useful when a user watched live or wants to record a viewing at the air-date for stats / archival purposes.
* *Very long time ago* — the `1970-01-01T00:00:01Z` placeholder. Helper line: *"I don't remember"*. The option that maps to this documented sentinel.
**Recently used** (history): the last two or three timestamps the user picked across recent date selections, surfaced so they don't have to re-build a complex date if they're tagging multiple items at the same time.
Plus the full calendar grid for explicit picks. Third-party apps that build a "When did you watch this?" experience without the *Very long time ago* option force users into picking a fake date — that's worse data for everyone (the user's history, Simkl's stats, your app's later analytics) than just recording an honest *"I don't remember"*.
### Why this exists
Many users adopt Simkl long after they've watched hundreds of titles over the years. They want to record those watches honestly without inventing fake dates. The placeholder lets a client offer the *"I don't remember"* option that maps to a stable, documented server value.
simkl.com renders these entries with the "Very long time ago" label in history lists, sorts them after dated entries, and never displays the literal `1970-01-01` to end users — clients should do the same.
# Errors and status codes
Source: https://api.simkl.org/conventions/errors
Every HTTP status code Simkl returns, what it means, and how to handle it.
Simkl uses standard HTTP status codes. Error responses always have a JSON body with `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"
}
```
**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
| 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 |
**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](/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` |
**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"
}
```
### 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 with [OAuth 2.0 or PIN](/authentication). Simkl 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.
```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"
```
### Forbidden
`403` — Refused. The request was understood but the caller is not permitted to perform it.
**Known `error` values**
| `error` | When |
| ----------------- | ------------------------------------------------------------------------------------------- |
| `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.
```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.
**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.
```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"
}
```
### client\_id failed
`412` — Your `client_id` is missing, wrong, suspended, or has hit a request limit.
**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.
* App hit a per-`client_id` request cap — see [Rate limits](/resources/rate-limits).
**Fix**
Double-check the value. If it's correct, [reach out on Discord](https://discord.gg/MJsWNE4).
```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` — Rate limit hit.
**Fix**
Implement [exponential backoff](#exponential-backoff). Cache responses aggressively. Use [Trending](/api-reference/trending) and [Calendar](/api-reference/calendar) CDN files instead of polling. Full guidance: [Rate limits](/resources/rate-limits).
```json title="429 Too Many Requests" theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"error": "rate_limit",
"code": 429
}
```
## 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 transient errors (`429`, `500`, `502`, `503`):
| 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 || ![429, 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.
# Extended info
Source: https://api.simkl.org/conventions/extended-info
Which endpoints return rich data by default and which ones still take an `extended` query parameter.
Simkl returns different amounts of data depending on the endpoint. Most lookups now return the rich record by default — you only need the `extended` parameter on a small set of endpoints, and the accepted values vary per endpoint.
## At a glance
| Endpoint family | Default response | `extended` parameter? |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| [`GET /movies/:id`](/api-reference/simkl/get-movie), [`GET /tv/:id`](/api-reference/simkl/get-tv-show), [`GET /anime/:id`](/api-reference/simkl/get-anime) | **Full record** — title, year, ids, overview, genres, ratings, poster, fanart, trailers, recommendations | Not needed. |
| [`GET /tv/episodes/:id`](/api-reference/simkl/get-tv-episodes), [`GET /anime/episodes/:id`](/api-reference/simkl/get-anime-episodes) | **Full episode list** with metadata | Not needed. |
| [`GET /sync/all-items/:type/:status`](/api-reference/simkl/get-all-items) | **Rich summary** — `last_watched`, `last_watched_at`, `watched_episodes_count`, `total_episodes_count`, `next_to_watch`, plus a `show`/`movie`/`anime` block with title, poster, runtime, ids. | Single value: `full`, `full_anime_seasons`, `ids_only`, or `simkl_ids_only` (see [below](#get-syncall-itemstypestatus--multiple-modes)). |
| [`GET /search/:type`](/api-reference/simkl/search-by-text) (text search) | `title`, `year`, `poster`, `ids` (simkl\_id, slug, tmdb), plus `title_en` / `title_romaji` / `type` for anime. | `extended=full` adds `all_titles`, `url`, `rank`, `status`, `ratings`, and `ep_count` (non-movies). |
| [`GET /search/id`](/api-reference/simkl/search-by-id), [`POST /search/file`](/api-reference/simkl/search-by-file) | Match metadata | `extended` is not used. |
## Summary endpoints — no extended needed
Calling a summary endpoint returns the full media object out of the box:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/tv/17465?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
You'll get back overview, ratings, genres, trailers, recommendations — everything that used to require `?extended=full`. The parameter is still accepted for backward compatibility, but it's a no-op on [`/movies/:id`](/api-reference/simkl/get-movie), [`/tv/:id`](/api-reference/simkl/get-tv-show), [`/anime/:id`](/api-reference/simkl/get-anime), [`/tv/episodes/:id`](/api-reference/simkl/get-tv-episodes), and [`/anime/episodes/:id`](/api-reference/simkl/get-anime-episodes).
## [`GET /sync/all-items/:type/:status`](/api-reference/simkl/get-all-items) — multiple modes
This is the only endpoint where `extended` takes one of several distinct mode values (not a comma-separated list). The default response is already **rich** — `extended` modes either **reduce** it to a slimmer ID-only payload (for fast deletion-reconciliation diffs) or **enhance** it with episode-level data and full metadata.
| Value | What you get |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| *(omitted)* | **Default rich summary** per item: `last_watched`, `last_watched_at`, `watched_episodes_count`, `total_episodes_count`, `not_aired_episodes_count`, `next_to_watch`, `added_to_watchlist_at`, `user_rating`, `user_rated_at`, `status`, plus a `show` / `movie` / `anime` block with title, poster, year, runtime, and ids. |
| `extended=simkl_ids_only` | **Reduces** to just `ids.simkl` per item — smallest possible delta payload, ideal for diffing against a local cache. |
| `extended=ids_only` | **Reduces** to the full `ids` block (Simkl + every external catalog ID) without other metadata. |
| `extended=full` | **Enhances** the default with genres, ratings, full metadata, and a `seasons[].episodes[]` block for items in `watching` / `plantowatch` / `hold` statuses. Episode entries contain `number` only — per-episode `watched_at` requires the separate `episode_watched_at=yes` parameter. |
| `extended=full_anime_seasons` | Same as `extended=full`, plus anime entries gain a show-level `mapped_tvdb_seasons: [n, …]` array (each Simkl/AniDB season's matching TVDB season number) and a per-episode `tvdb: { season, episode }` block. Not applied to anime movies. |
### Related modifiers
Two query parameters interact with `extended` on `/sync/all-items` but are separate flags:
* **`include_all_episodes=yes`** *(or `=original`)* — by default, `extended=full` doesn't load `seasons[].episodes[]` for items in `completed` or `dropped` buckets. Set `include_all_episodes=yes` to load episodes on those buckets too (synthesizes virtual episode rows for items without per-episode data). Use `=original` to load only real per-episode rows, skipping virtual synthesis.
* **`episode_watched_at=yes`** — adds per-episode `watched_at` timestamps to whatever episodes are loaded. Modifier only — episodes must already be present via `extended=full` or `include_all_episodes=yes`. Without this flag, episode entries carry only `number`.
See the [`/sync/all-items` reference](/api-reference/simkl/get-all-items) for the full response shape per mode, and the [Sync guide](/guides/sync) for the two-phase model that puts these together. For rewatch-session reads, see the [Rewatches guide](/guides/rewatches).
## Search — only [`/search/:type`](/api-reference/simkl/search-by-text) uses `extended`
[`/search/:type`](/api-reference/simkl/search-by-text) (free-text search) accepts only `extended=full` (no comma-separated values, no other modes).
**Default match shape** (without `extended`): `title`, `year`, `poster`, `endpoint_type`, plus `ids` (`simkl_id`, `slug`, and `tmdb` when available). Anime entries also include `title_en`, `title_romaji`, and `type` (`tv` / `movie` / `ova` / `ona` / `special` / `music video`).
**With `extended=full`** the response adds:
* `all_titles` — alternate titles / aliases (only when more than one is known for the item)
* `url` — canonical simkl.com URL
* `rank` — Simkl rank for the type
* `status` — running / ended / cancelled / etc.
* `ratings` — Simkl + IMDb + MAL aggregate ratings (each present when available)
* `ep_count` — episode count, **non-movies only** (TV and anime)
[`/search/id`](/api-reference/simkl/search-by-id) and [`/search/file`](/api-reference/simkl/search-by-file) don't honour `extended` — they always return their default match shape. See those endpoints' pages for the actual response fields.
For [`/search/id`](/api-reference/simkl/search-by-id) specifically, use the per-catalog ID query parameter that matches what you have (`imdb`, `tmdb`, `tvdb`, `mal`, etc.). The response shape is fixed; there's no opt-in extension.
# Headers and required parameters
Source: https://api.simkl.org/conventions/headers
Required URL parameters and HTTP headers for every Simkl API request.
Every Simkl API request needs **three URL parameters** that identify your app, plus a **`User-Agent` header**. Endpoints that read or write user data also need an `Authorization: Bearer` token.
## Required URL parameters
Append these to **every** request URL — both public catalog calls and authenticated user calls:
```
/endpoint?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0
```
| Parameter | Required | Value |
| ------------- | -------- | --------------------------------------------------------------------------------------- |
| `client_id` | always | Your `client_id` from [your developer settings](https://simkl.com/settings/developer/). |
| `app-name` | always | Short, lowercase identifier for your app (e.g. `plex-scrobbler`, `kodi-trakt-bridge`). |
| `app-version` | always | The current version of your app, e.g. `1.0`, `2.4.1`. |
These three parameters help us see which apps are using the API, debug issues you report, and route around outages. They're cheap to send — please always include them.
## Required HTTP headers
| Header | Required | Value |
| --------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type` | on POST | `application/json` |
| `User-Agent` | always | A descriptive identifier for your app, ideally `name/version`. Examples: `PlexMediaServer/1.43.1.10540`, `kodi-simkl/0.9.2`, `MyAppName/2.4.1 (https://myapp.com)`. |
| `Authorization` | when token-required | `Bearer YOUR_ACCESS_TOKEN`. Required for endpoints that read or modify the user's library, scrobble session, ratings, settings, or playbacks. |
As an alternative to the `client_id` query parameter, you may pass it as a `simkl-api-key` header. Either works; query-param form is preferred because it makes the request fully self-describing in URL form.
## Putting it together
A typical authenticated call looks like:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/activities?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json"
```
A typical public call looks like:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/tv/17465?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
In the [API Reference](/api-reference/simkl/get-anime-airing) playground these values are auto-filled — paste your `client_id`, `app-name`, `app-version`, and `access_token` once and they'll be reused.
## HTTP verbs
| Verb | Use | Success status |
| -------- | -------------------- | ---------------------------- |
| `GET` | Retrieve a resource. | `200 OK` |
| `POST` | Create or update. | `201 Created` |
| `DELETE` | Remove a resource. | `200 OK` or `204 No Content` |
The full list of status codes Simkl returns lives on the [Errors](/conventions/errors) page.
# Images
Source: https://api.simkl.org/conventions/images
How to fetch poster, fanart, episode, and avatar images, with all available sizes — with live previews of each.
Simkl returns image **paths** (not full URLs) inside `poster`, `fanart`, `episode`, and `avatar` fields. You build the final URL by combining the `simkl.in` domain, a category prefix, the image path, a size suffix, and a file extension.
## Anatomy of a URL
| Part | Example | Notes |
| --------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Domain | `https://simkl.in` | Always start with this. |
| **Category** | `/posters/` | One of `/posters/`, `/fanart/`, `/episodes/`, `/avatars/`. |
| **Image path** | `74/74415673dcdc9cdd` | The value from the API field (`poster`, `fanart`, etc.). |
| **Size suffix** | `_w` | Determines pixel size — see each section below. |
| **Extension** | `.webp` | Always `.webp` for posters, episodes, and every fanart size except `_d`. Two exceptions: fanart `_d` is `.jpg` only ([details](#fanart)), avatars are `.jpg` only ([details](#avatars)). |
**Use `.webp` for posters, episodes, and fanart.**
Two `.jpg` exceptions:
* **Fanart `_d`** — original-resolution, darker tone, smaller filesize than `_medium.webp`. JPG only.
* **Avatars** — small enough that the format doesn't matter; served as JPG.
## Code samples
Each tab covers a different real-world scenario. All samples handle missing/null paths by falling back to the placeholders documented in [Fallback when images are missing](#fallback-when-images-are-missing).
Single helper covering all four kinds, with null-safe fallback for posters.
```js Node / TypeScript theme={"theme":{"light":"github-light","dark":"vesper"}}
const SIMKL_IMG_BASE = 'https://simkl.in';
function imageUrl(path, kind = 'posters', size = '_w', ext = '.webp') {
if (!path) {
if (kind !== 'posters') return null; // hide fanart/episode/avatar — no placeholder
const ph = size === '_s' ? '_s' : '_c';
return `${SIMKL_IMG_BASE}/poster_no_pic${ph}.png`;
}
return `${SIMKL_IMG_BASE}/${kind}/${path}${size}${ext}`;
}
imageUrl('74/74415673dcdc9cdd', 'posters', '_w');
// → https://simkl.in/posters/74/74415673dcdc9cdd_w.webp
imageUrl(null, 'posters', '_c');
// → https://simkl.in/poster_no_pic_c.png
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
SIMKL_IMG_BASE = 'https://simkl.in'
def image_url(path, kind='posters', size='_w', ext='.webp'):
if not path:
if kind != 'posters':
return None # no placeholder for fanart/episode/avatar
ph = '_s' if size == '_s' else '_c'
return f'{SIMKL_IMG_BASE}/poster_no_pic{ph}.png'
return f'{SIMKL_IMG_BASE}/{kind}/{path}{size}{ext}'
```
```swift Swift theme={"theme":{"light":"github-light","dark":"vesper"}}
enum SimklImage {
static let imgBase = "https://simkl.in"
static func url(path: String?, kind: String = "posters",
size: String = "_w", ext: String = ".webp") -> String? {
guard let p = path, !p.isEmpty else {
guard kind == "posters" else { return nil }
let ph = size == "_s" ? "_s" : "_c"
return "\(imgBase)/poster_no_pic\(ph).png"
}
return "\(imgBase)/\(kind)/\(p)\(size)\(ext)"
}
}
```
```kotlin Kotlin theme={"theme":{"light":"github-light","dark":"vesper"}}
object SimklImage {
private const val IMG_BASE = "https://simkl.in"
fun url(path: String?, kind: String = "posters",
size: String = "_w", ext: String = ".webp"): String? {
if (path.isNullOrEmpty()) {
if (kind != "posters") return null
val ph = if (size == "_s") "_s" else "_c"
return "$IMG_BASE/poster_no_pic$ph.png"
}
return "$IMG_BASE/$kind/$path$size$ext"
}
}
```
```go Go theme={"theme":{"light":"github-light","dark":"vesper"}}
package simklimg
import "fmt"
const imgBase = "https://simkl.in"
func URL(path, kind, size, ext string) string {
if path == "" {
if kind != "posters" {
return ""
}
ph := "_c"
if size == "_s" {
ph = "_s"
}
return fmt.Sprintf("%s/poster_no_pic%s.png", imgBase, ph)
}
return fmt.Sprintf("%s/%s/%s%s%s", imgBase, kind, path, size, ext)
}
```
Generate a `` so the browser picks the right size for the user's screen. The `src` falls back to the placeholder when `poster` is null.
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
```
Always set explicit `width` / `height` so the browser reserves layout space and avoids cumulative layout shift (CLS).
A component with built-in fallback and `onError` recovery for stale paths.
```jsx theme={"theme":{"light":"github-light","dark":"vesper"}}
import { useState } from 'react';
const SIMKL_IMG_BASE = 'https://simkl.in';
const PLACEHOLDER = `${SIMKL_IMG_BASE}/poster_no_pic_c.png`;
export function Poster({ path, size = '_c', alt = '' }) {
const initial = path ? `${SIMKL_IMG_BASE}/posters/${path}${size}.webp` : PLACEHOLDER;
const [src, setSrc] = useState(initial);
return (
setSrc(PLACEHOLDER)}
/>
);
}
```
`onError` falls back if the image 404s for any reason (e.g. the title was merged and the path is stale).
Show a tiny `_s48` fanart thumbnail upscaled with a CSS blur, then swap in the full image when loaded. **Fanart has no placeholder** — if `path` is null, render nothing.
```jsx theme={"theme":{"light":"github-light","dark":"vesper"}}
import { useState } from 'react';
const SIMKL_IMG_BASE = 'https://simkl.in';
export function Fanart({ path }) {
const [loaded, setLoaded] = useState(false);
if (!path) return null;
const tiny = `${SIMKL_IMG_BASE}/fanart/${path}_s48.webp`;
const full = `${SIMKL_IMG_BASE}/fanart/${path}_medium.webp`;
return (
| Suffix | Size | File size | Example |
| -------- | --------- | --------- | -------------------------------------------- |
| `_24` | 24 × 24 | \~0.7 KB | [View](https://simkl.in/avatars/1/1_24.jpg) |
| `_100` | 100 × 100 | \~5.6 KB | [View](https://simkl.in/avatars/1/1_100.jpg) |
| *(none)* | 200 × 200 | \~17 KB | [View](https://simkl.in/avatars/1/1.jpg) |
| `_256` | 256 × 256 | \~26 KB | [View](https://simkl.in/avatars/1/1_256.jpg) |
| `_512` | 512 × 512 | \~79 KB | [View](https://simkl.in/avatars/1/1_512.jpg) |
Avatars are JPG-only.
## Fallback when images are missing
When a `poster`, `fanart`, or `episode` field is `null` (or the path is empty), use these built-in placeholders. They live at the root of `simkl.in`:
| URL | Use for | File size | Example |
| ------------------------------------------- | ---------------------------------- | --------- | ------------------------------------------------- |
| `https://simkl.in/poster_no_pic.png` | Default — full-size missing-poster | \~25 KB | [View](https://simkl.in/poster_no_pic.png) |
| `https://simkl.in/poster_no_pic_c.png` | Match `_c` posters (170 × 250) | \~9 KB | [View](https://simkl.in/poster_no_pic_c.png) |
| `https://simkl.in/poster_no_pic_c_grey.png` | Match `_c` with a softer grey tone | \~24 KB | [View](https://simkl.in/poster_no_pic_c_grey.png) |
| `https://simkl.in/poster_no_pic_s.png` | Match `_s` posters (40 × 57) | \~1.7 KB | [View](https://simkl.in/poster_no_pic_s.png) |
**Fanart has no dedicated placeholder.** When `fanart` is `null`, hide the fanart element rather than showing a broken/empty image — most apps render a solid color or use the poster's blurred-up `_s48` thumbnail behind a tint instead.
## Caching
**Cache images by URL forever.** The image at a given URL never changes — once you've downloaded it, don't fetch it again. Re-downloading wastes your users' bandwidth and ours.
# Watchlist statuses
Source: https://api.simkl.org/conventions/list-statuses
The watchlist statuses Simkl uses — and the per-type rules. Movies don't have watching or hold; TV and anime do.
Every item in a user's library lives in exactly one status bucket. **The set of valid statuses is not the same across types** — movies are restricted to three statuses, while TV shows and anime get all five.
## Per-type matrix
| Status | Movies | TV | Anime | Display name |
| ------------- | :----: | :-: | :---: | ------------- |
| `watching` | — | ✓ | ✓ | Watching |
| `plantowatch` | ✓ | ✓ | ✓ | Plan to Watch |
| `hold` | — | ✓ | ✓ | On Hold |
| `dropped` | ✓ | ✓ | ✓ | Dropped |
| `completed` | ✓ | ✓ | ✓ | Completed |
**TV and anime accept the same five statuses.** The difference that matters in code is **movies vs everything else** — movies skip `watching` and `hold`.
## Why movies are different
Movies are single-session content — there's no progress to "pause" the way a 10-episode TV season has. Simkl reflects this in the API in four concrete ways:
**1. [`POST /sync/add-to-list`](/api-reference/simkl/add-to-list) silently upgrades `watching` to `completed` for movies.** Posting `to: "watching"` for a movie is not an error — the API quietly converts it to `completed` and records the watch. Posting `to: "hold"` for a movie has no defined behavior; don't rely on it.
**2. [`GET /sync/all-items/movies/watching`](/api-reference/simkl/get-all-items) and `/movies/hold` return empty.** The URL is accepted (won't 404), but no movie can be in those states, so the response is always empty. Don't paginate these endpoints — there's nothing to fetch.
**3. [`GET /sync/activities`](/api-reference/simkl/get-activities) strips `watching` and `hold` from the `movies` block.** Build your client around this shape — don't expect `movies.watching` or `movies.hold` keys.
**4. [`POST /users/USER_ID/stats`](/api-reference/simkl/get-user-stats) skips `watching` and `hold` for movies.** Stats responses don't include those buckets for movies. Plan-to-watch movies show up; "in progress" / "on hold" never do.
## Where statuses appear
`GET /sync/all-items/:type/:status` — read items in one bucket. Use `all` for everything.
`POST /sync/add-to-list` — set `to` to one of the five statuses to move the item between buckets.
`POST /sync/history/remove` — the canonical way to remove a watched item from the user's history.
`GET /sync/activities` returns timestamps per status bucket — only re-sync the watchlists whose timestamp moved.
**To remove an item, use [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history)** — not `POST /sync/add-to-list` with `to: "remove"`. The history endpoint is the canonical removal path; it cleanly un-marks the item watched and clears it from the user's library.
**Removing also clears the rating.** If the user had rated the item, Simkl wipes the rating along with the watch record — you'll see both `removed_from_list` and `rated_at` move on the next `/sync/activities` call.
## Status vs. progress
`status` says **where in the library** an item lives. The `watched_episodes_count`, `total_episodes_count`, and `last_watched_at` fields say **how far the user is**.
A TV show in `status: watching` with 3 of 10 episodes watched is normal. A show in `status: completed` should always have `watched_episodes_count` equal to `total_episodes_count`. The two evolve together but they're separate concepts.
For movies, `status: completed` simply means the user marked the movie watched — there's no episode count to track.
## Quick examples
```bash Read all "watching" anime theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/all-items/anime/watching?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN"
```
```bash Mark a movie completed theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/add-to-list?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "completed", "movies": [{ "ids": { "simkl": 53536 } }] }'
```
```bash Drop a TV show theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/add-to-list?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "dropped", "shows": [{ "ids": { "tmdb": "1399" } }] }'
```
```bash Pause an anime ("on hold") theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/add-to-list?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "hold", "anime": [{ "ids": { "mal": "11757" } }] }'
```
```bash Remove a show from the user's library theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history/remove?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "shows": [{ "ids": { "tmdb": "1399" } }] }'
```
## Related
Read and write watchlists, history, and ratings.
`POST /sync/history` recipes that don't go through scrobble.
The shared shape every watchlist endpoint returns.
# Null and missing values
Source: https://api.simkl.org/conventions/null-values
Five different things a missing value can mean in the Simkl API, and how to handle each one.
`null` in a Simkl response is never noise — it's one of five specific signals. This page is the master reference; schema descriptions link back here by type.
**Never happened yet** — event hasn't fired
**Doesn't apply** — key omitted entirely
**Empty result** — whole response null
**Unknown** — data not on file
**End state** — journey complete
## Type 1 — Never happened yet
**Field present, value `null`** — the event hasn't fired for this user yet, but the field is a normal part of the model. Once it happens, the value flips to a real value and never reverts to `null`.
**Where you'll see it**
| Endpoint | Field | When it's null |
| ------------------------------------------------------------- | ------------------------------ | ------------------------------------------- |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | every bucket timestamp | User has never had activity in that bucket |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `last_watched_at` | Plantowatch items — user hasn't watched yet |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `user_rated_at`, `user_rating` | Items the user hasn't rated |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `last_watched` | Shows the user hasn't started |
**Handling**
```js JavaScript {2,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
if (item.last_watched_at !== null) {
showLastWatched(item.last_watched_at);
}
// ✗ Wrong — gives a "watched on Jan 1 1970" UI
showLastWatched(item.last_watched_at ?? '1970-01-01T00:00:00Z');
```
```python Python {2,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
if item.get('last_watched_at') is not None:
show_last_watched(item['last_watched_at'])
# ✗ Wrong
show_last_watched(item.get('last_watched_at') or '1970-01-01')
```
## Type 2 — Doesn't apply to this type
**Key omitted entirely** — not set to `null`, the property doesn't exist on this object. Iterating keys won't return it; `'fieldName' in obj` returns `false`.
**Where you'll see it**
| Endpoint | Type | What's omitted |
| -------------------------------------------------------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------- |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | `movies` block | `watching` and `hold` keys (movies can't be in those statuses) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | Movie records | Episode-related fields (`seasons`, `last_watched`, etc.) |
| Movie / TV records | — | Anime-only IDs (`mal`, `anidb`, `anilist`, `kitsu`) and `anime_type` |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | TV specials | `season` AND `episode` both omitted on `type: "special"` items |
| [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | Every anime episode | `season` omitted (AniDB numbers anime sequentially, no per-season concept) |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | TV-classified shows | `relations` array omitted entirely (anime catalog only — TV records skip this field) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?memos=yes` | every entry | `memo` key omitted (gated on the query param) |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?next_watch_info=yes` | every entry | `next_to_watch_info` key omitted |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?allow_rewatch=yes` | rewatch-session rows | `is_rewatch`, `rewatch_id`, `rewatch_status` keys omitted entirely; only the canonical entry returns |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?extended=full_anime_seasons` | anime entries | `mapped_tvdb_seasons` array omitted |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) without `?episode_tvdb_id=yes` | episode rows | `tvdb` block on each episode omitted |
See [Watchlist statuses](/conventions/list-statuses) for the full per-type matrix.
**`/sync/all-items/anime` entries wrap in `show:`, by design.** Each
item in the `anime` array nests its media object under a key called
`show:`, not `anime:`. This is intentional cross-catalog compatibility:
apps that source their data from **TMDB or TVDB** treat anime as shows
(those catalogs have no separate "anime" concept), so the `show:`
wrapper keeps Simkl's sync responses drop-in compatible with code
written against TMDB/TVDB-shaped data. Anime-only fields (`anime_type`,
mal/anidb/anilist/kitsu IDs) sit alongside on the outer item and on
`show.ids` respectively.
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct — same iteration pattern works for shows/, anime/, and
// future media types that follow the show-shaped TMDB/TVDB model
const list = await fetch('/sync/all-items/anime').then(r => r.json());
list.anime.forEach(item => {
const metadata = item.show; // anime use `show:` for the same
// reason TVDB indexes anime under shows
const ids = metadata.ids; // ids.simkl, ids.mal, ids.anidb, ...
const animeFormat = item.anime_type; // anime-specific outer-item field
});
```
**Handling**
```js JavaScript {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
if ('watching' in activities.tv_shows) {
// TV has a watching bucket
}
// ✗ Wrong — activities.movies.watching is undefined, not null
if (activities.movies.watching === null) {
/* never true */
}
```
```python Python {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
if 'watching' in activities['tv_shows']:
pass
# ✗ Wrong — KeyError
ts = activities['movies']['watching']
```
## Type 3 — Empty result set
**The whole response is `null` or an empty array** — the endpoint accepted the request but there are no items to return. The URL is valid; the call returns `200`; the body is just empty.
**Where you'll see it**
| Endpoint | When it's empty |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| [`GET /sync/all-items/movies/watching`](/api-reference/simkl/get-all-items) | Always — movies can't have this status, the route is reachable but the data set is structurally empty |
| [`GET /sync/all-items/movies/hold`](/api-reference/simkl/get-all-items) | Same |
| [`GET /sync/all-items/{type}/{status}`](/api-reference/simkl/get-all-items) | Any bucket the user simply has no items in |
| [`POST /search/random`](/api-reference/simkl/search-random) | Returns `{"error": "not_found"}` when no item matches the filters |
**Handling**
```js JavaScript {3,8} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
const items = await fetch('/sync/all-items/movies/watching').then(r => r.json());
if (!items) return; // empty bucket — bail early
// ✗ Wrong — TypeError if response is null
const items = await fetch(...).then(r => r.json());
items.movies.forEach(...);
```
```python Python {3,7} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
data = r.json()
if data is None:
return
# ✗ Wrong — AttributeError
for m in r.json()['movies']:
...
```
## Type 4 — Unknown / data not on file
**Field present, value `null`** — the data point is supposed to have a value but Simkl genuinely doesn't have it yet. Render a placeholder; don't display the literal `null`.
**Where you'll see it**
| Endpoint | Field | When it's null |
| --------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
| [`GET /tv/airing`](/api-reference/simkl/get-tv-airing) | `date` | Air date not on file yet |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `description` | Episode synopsis unknown |
| [`GET /tv/episodes/{id}`](/api-reference/simkl/get-tv-episodes) | `img` | Episode still not on file |
| [`GET /movies/{id}`](/api-reference/simkl/get-movie) | `ratings.imdb`, etc. | No external rating known |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | `en_title` | Localized English title not on file (e.g. Death Note returns `null`) |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `network` | Mirrored from the upstream TVDB record. `null` when TVDB has no network on file (mostly recent anime). |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `airs` | Recurring schedule not on file (older shows, anime films) |
| [`GET /tv/{id}`](/api-reference/simkl/get-tv-show), [`/anime/{id}`](/api-reference/simkl/get-anime) | `status` | Catalog `Status` column blank — rare; status is normally one of `tba`, `ended`, `airing` |
| Catalog endpoints | `runtime` | Runtime not tracked |
**`network` reflects the upstream catalog (TVDB).** The value is what
TVDB classifies the record under — for streaming-only shows TVDB lists
the platform as the "network" (`"Prime Video"`, `"Netflix"`, `"Apple TV+"`),
for traditional broadcast it's the broadcaster (`"HBO"`, `"AMC"`, `"CBS"`).
Don't infer distribution model from this field; treat it as an opaque
display string from the source catalog.
**`null` vs empty string.** Older Simkl records sometimes return an empty
string `""` for a field rather than `null` (e.g. `en_title: ""` on classic
anime, before the API standardized on `null`). Both mean Type 4 — data
not on file. Check both:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const englishTitle = item.en_title || 'No English title on file';
// matches null AND empty string
```
**`memo: {}` is a Type 4 variant.** On
[`GET /sync/all-items?memos=yes`](/api-reference/simkl/get-all-items),
items the user has set a memo on return:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "memo": { "text": "Loved this season", "is_private": false } }
```
Items without a memo return an **empty object**:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "memo": {} }
```
NOT `null`, NOT missing — `{}`. The empty-object marker keeps the key
shape consistent (client code can always do `item.memo.text || ''`
without an existence check or a null-coalesce). When `?memos=yes` is
NOT set, the `memo` key is omitted entirely (Type 2).
**Handling**
```jsx JSX {2,5} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
{episode.description ?? 'No description yet.'}
// ✓ Correct — guard date-display code
{episode.date ? formatDate(episode.date) : 'TBA'}
```
```python Python {2} theme={"theme":{"light":"github-light","dark":"vesper"}}
# ✓ Correct
runtime_str = f"{movie['runtime']} min" if movie.get('runtime') else "—"
```
## Type 5 — End state reached
**Field present, value `null`** — the value used to be meaningful but the workflow has ended; the field is intentionally cleared. Render the "complete" state in your UI.
**Where you'll see it**
| Endpoint | Field | When it's null |
| ---------------------------------------------------------------------------------- | -------------------- | ----------------------------------------- |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) | `next_to_watch` | Show in `completed` — no further episodes |
| [`GET /sync/all-items`](/api-reference/simkl/get-all-items) `?next_watch_info=yes` | `next_to_watch_info` | Same — completed show, nothing remaining |
**Handling**
```jsx JSX {2-4} theme={"theme":{"light":"github-light","dark":"vesper"}}
// ✓ Correct
{show.next_to_watch
? Next: {show.next_to_watch}
: All caught up}
```
```python Python {1,3} theme={"theme":{"light":"github-light","dark":"vesper"}}
if show.get('next_to_watch'):
print(f"Next: {show['next_to_watch']}")
else:
print("All caught up")
```
## Empty / Missing response shape — by endpoint
The API uses **five different shapes** for "this request was valid but
there's no data". The table below tells you what to expect at each
endpoint so a single parsing path can handle them all.
### The five shapes you'll meet
Empty array. Safe to iterate.
Top-level `null`. `r.json()` returns `None`.
Empty object. `len()` is 0.
Answer in `Location:` header. No body.
Server returned unrelated discover data instead of an error.
**Status-code lies.** Five endpoints return `200 OK` with an empty
shape rather than `404 Not Found`. Branch on the body shape, not just
the status — see the rows tagged in the table below.
Conversely, three CDN endpoints (`/calendar/...`, `/discover/...`)
return `404` with a **plain string body**, not the JSON error envelope
the rest of the API uses. `r.json()` will raise on these — wrap the
parse.
### Per-endpoint shape matrix
| Endpoint | Trigger | Status | Body shape | How to handle |
| -------------------------------------------------- | --------------------------------------------- | :-----: | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `GET /movies/{id}` | unknown numeric Simkl ID | **200** | `[]` | Empty array (Type 3). Safe to iterate. |
| `GET /tv/{id}` | unknown numeric Simkl ID | **200** | `[]` | Same. |
| `GET /anime/{id}` | unknown numeric Simkl ID | **200** | `[]` | Same. |
| `GET /tv/episodes/{id}` | unknown parent show ID | **200** | `[]` | Same. |
| `GET /anime/episodes/{id}` | unknown parent anime ID | **200** | `[]` | Same. |
| `GET /tv/episodes/{id}` | non-numeric `id` | **200** | `[]` | Same. **No 404** for non-numeric id. |
| `GET /anime/episodes/{id}` | non-numeric `id` | **200** | `[]` | Same. |
| `GET /movies/{id}` | non-numeric `id` | 404 | `{error, code, message?}` | JSON error envelope. |
| `GET /tv/{id}` | non-numeric `id` | **200** | `object{...}` | **Fallthrough** — server returns discover data (`top_aired_fanarts`, etc.) instead of an error — NOT a `ShowDetail` shape. |
| `GET /anime/{id}` | non-numeric `id` | **200** | `object{...}` | Same fallthrough behavior. |
| `GET /tv/episodes/` | empty `id` (trailing slash) | 400 | `{error: "empty_id", code: 400}` | JSON error envelope. |
| `GET /anime/episodes/` | empty `id` | 400 | `{error: "empty_id", code: 400}` | Same. |
| `GET /sync/activities` | fresh account (no activity yet) | 200 | `object{}` w/ all-null timestamps | Type 1 nulls inside, never null at top. |
| `GET /sync/all-items` | brand-new user (no library, no `type` filter) | 200 | `{}` | Empty object. Iterating keys yields nothing. |
| `GET /sync/all-items/{type}` | empty bucket | 200 | `object{type: []}` | Object wrapper present; inner array is empty. |
| `GET /sync/all-items/movies/watching` | movies can't be `watching` | **200** | `{}` | **Empty object** — not `null`, not `[]`. Iterating keys yields nothing. |
| `GET /sync/all-items/movies/hold` | movies can't be `hold` | **200** | `{}` | Same. |
| `GET /sync/all-items?memos=yes` | item with no memo set | **200** | `{ "memo": {} }` per item | Empty object inside the per-item shape — NOT null, NOT missing. Type 4 variant. |
| `GET /sync/all-items/badtype` | bad `type` segment | **200** | `object{...}` | **Fallthrough** — server returns a default response with cross-type data. **No 400/404**. |
| `GET /sync/all-items/{type}/badstatus` | bad `status` segment | 200 | `object{type: []}` | Falls back to "all of that type", just empty. **No 400/404**. |
| `GET /sync/ratings/{type}/{1-10}` | user has no ratings at this score | **200** | `{}` | Empty object, NOT empty array. |
| `GET /sync/ratings/{type}/0` | rating \< 1 (out of range) | **200** | `{}` | Same — no validation error. |
| `GET /sync/ratings/{type}/11` | rating > 10 (out of range) | **200** | `{}` | Same. |
| `GET /sync/ratings/badtype/{N}` | bad `type` | **200** | `{}` | Same. |
| `GET /sync/playback/{type}` | empty | 200 | `array[...]` | Returns array (possibly empty). |
| `GET /sync/playback/badtype` | bad `type` | 404 | `{error: "url_failed", code: 404}` | **404 here, but 200 fallthrough on /sync/all-items/badtype** — be aware of the asymmetry. |
| `GET /search/{type}` | missing `q` parameter | **200** | `[]` | Empty array. |
| `GET /search/badtype` | bad type | **200** | `null` | **Top-level null.** `r.json()` returns `None`. |
| `GET /search/id` | unknown external id | **200** | `[]` | Empty array. |
| `GET /search/id` | no id param at all | **200** | `[]` | Same. |
| `POST /search/random` | empty body | **200** | `object{...}` | Returns a random item — never empty. |
| `POST /search/random` | filters yield no match | **200** | `{error: "not_found", ...}` | **Status is 200 but body is the error envelope.** Branch on `'error' in body`, not on status. |
| `POST /search/file` | empty body | **200** | `null` | **Top-level null.** |
| `GET /ratings` | bad params, no id | **200** | `null` | **Top-level null.** |
| `GET /ratings` | unknown imdb id | **200** | `null` | Same. |
| `GET /ratings/{type}` | missing `user_watchlist` param | **200** | `null` | Same — even WITHOUT a Bearer token (auth gate is on the param). |
| `POST /users/settings` | normal | 200 | `object{user, account, connections}` | Always a populated object for the authed user. |
| `POST /users/{user_id}/stats` | unknown `user_id` | **200** | `object{...}` w/ zero stats | Returns a zero-counts record, NOT 404. (Cross-account access intentional.) |
| `GET /users/recently-watched-background/{user_id}` | numeric unknown user | **200** | `null` | **Top-level null** for a numeric-but-unknown id. |
| `GET /users/recently-watched-background/{user_id}` | non-numeric user\_id | 404 | `{error: "user_id_failed", code: 404}` | JSON error envelope. |
| `GET /redirect` | no id at all | **301** | empty body, `Location: //simkl.com` | **301 redirect** — body is empty; answer is in the header. Don't `r.json()`. |
| `GET /redirect` | unknown external id | **301** | empty body, `Location: //simkl.com` | Same — fallback to simkl.com root. |
| `GET /redirect` | valid id | **301** | empty body, `Location: //simkl.com///` | Always 301 — never returns JSON. |
| `GET /oauth/pin/{user_code}` | unknown `user_code` | 200 | `object{...}` (pending status) | Returns the pending-PIN shape, NOT 404. |
| `GET /calendar/{type}.json` *(CDN)* | normal | 200 | `array[...]` | — |
| `GET /calendar/{year}/{month}/{type}.json` *(CDN)* | far-future month | **404** | **plain string body** | **`r.json()` will raise.** Wrap the parse. |
| `GET /calendar/badtype.json` *(CDN)* | bad type | **404** | **plain string body** | Same. |
| `GET /discover/trending/{file}.json` *(CDN)* | normal | 200 | `array[...]` or `object{...}` (combined) | — |
| `GET /discover/trending/badcombo.json` *(CDN)* | unknown filename | **404** | **plain string body** | Same — CDN errors are not JSON. |
### Defensive parser pattern
If you're writing a generic Simkl client wrapper, this defensive
pattern handles every shape above:
```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}}
async function safeJson(response) {
// CDN 404s and the redirect-without-follow are not JSON.
if (response.status >= 300 && response.status < 400) return null;
if (!response.headers.get('content-type')?.includes('json')) return null;
let body;
try { body = await response.json(); }
catch { return null; } // bad-content-type 404 string
if (body === null) return null; // top-level null (Type 3)
if (Array.isArray(body) && body.length === 0) return []; // empty array
if (typeof body === 'object' &&
Object.keys(body).length === 0) return {}; // empty object
if (body && typeof body === 'object' &&
'error' in body && response.status === 200) {
// 200-with-error-envelope (e.g. POST /search/random with no match)
return null;
}
return body;
}
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
def safe_json(r):
"""Returns the parsed body or None for any "no useful data" shape."""
if 300 <= r.status_code < 400:
return None # redirect — header has the answer
ct = (r.headers.get("content-type") or "").lower()
if "json" not in ct:
return None # CDN 404 string body
try:
body = r.json()
except ValueError:
return None
if body is None:
return None # top-level null
if isinstance(body, list) and not body:
return [] # empty array
if isinstance(body, dict):
if not body:
return {} # empty object
if r.status_code == 200 and "error" in body:
return None # 200-with-error envelope
return body
```
### Pinning a shape in your code
If your integration depends on a specific row of this table, pin it as
a comment in your code. When the table changes on a future doc release,
your in-code reference makes the diff easier to spot during a routine
dependency-update review.
## Quick decision table
| Symptom | Type | Means | Do |
| -------------------------------- | :---: | --------------------- | -------------------------------------- |
| Timestamp `null` on activities | **1** | Never happened yet | Don't show "last activity" |
| Key isn't in the object | **2** | Doesn't apply to type | `'key' in obj` check, or skip silently |
| Whole response is `null` | **3** | Bucket is empty | Render empty state |
| Description/image is `null` | **4** | Data not on file | Render placeholder ("TBA", "—") |
| `next_to_watch` `null` on a show | **5** | Show is completed | Render "all caught up" |
## Related
Which statuses are valid per type. The reason `watching` and `hold` are omitted from movie blocks (Type 2).
Field-by-field shape of Movie / Show / Anime / Episode — which fields can be null, which are always present.
ISO-8601, the `Z` suffix, and the `1970-01-01T00:00:01Z` "very long time ago" placeholder.
How error envelopes differ from null responses, and when you'll see each.
# Pagination
Source: https://api.simkl.org/conventions/pagination
How paginated endpoints work — request size, page numbers, and the response headers Simkl returns.
Some Simkl endpoints return many items and are paginated. Paginated endpoints are marked with **📄 Pagination** in the [API Reference](/api-reference/introduction).
The pagination contract is consistent across these endpoints — same `page` and `limit` query parameters, same `X-Pagination-*` response headers, same cap on `page` (max **20**) — but **the default and maximum `limit` differ per endpoint family.** Always check the per-endpoint defaults below before assuming a value.
## Per-endpoint defaults
| Endpoint family | Default `limit` | Max `limit` | Max `page` |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ----------- | ---------- |
| [`GET /search/{type}`](/api-reference/simkl/search-by-text) *(text search)* | **10** | 50 | 20 |
| [`GET /tv/genres/...`](/api-reference/simkl/get-tv-genres), [`GET /anime/genres/...`](/api-reference/simkl/get-anime-genres), [`GET /movies/genres/...`](/api-reference/simkl/get-movies-genres) *(by genre)* | **60** | 60 | 20 |
| [`GET /tv/premieres/{param}`](/api-reference/simkl/get-tv-premieres), [`GET /anime/premieres/{param}`](/api-reference/simkl/get-anime-premieres) | **60** | 60 | 20 |
`page` defaults to `1` on every paginated endpoint.
Endpoints not listed here either don't paginate (they use a different model — [`GET /sync/all-items`](/api-reference/simkl/get-all-items) uses `date_from` for incremental sync) or accept a much larger fixed window (e.g. [`GET /sync/playback`](/api-reference/simkl/get-playback-sessions) returns up to 10,000 items in one shot). When in doubt, check the endpoint's own reference page.
## Query parameters
| Parameter | Type | Description |
| --------- | --------------- | ---------------------------------------------------------------------------- |
| `page` | integer (1–20) | Which page to return. Defaults to `1`. |
| `limit` | integer (1–max) | Items per page. Default and maximum vary per endpoint — see the table above. |
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
# Page 2 of TV genre browse, 60 items per page (the max for this endpoint).
curl "https://api.simkl.com/tv/genres/all/all/all/all/all/popularity?page=2&limit=60&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
If you pass a `limit` above the endpoint's cap, the server silently clamps it down — no error, no warning, just fewer items than you asked for. Same for `page` above `20`. Read the response headers to know what you actually got.
## Response headers
Every paginated response includes:
| Header | Meaning |
| ------------------------- | --------------------------------------------------------------------- |
| `X-Pagination-Page` | The current page number. |
| `X-Pagination-Limit` | Items per page (after the server applies any clamping). |
| `X-Pagination-Page-Count` | Total number of pages available — bounded by the `page` cap (max 20). |
| `X-Pagination-Item-Count` | Total number of items matching the request across all pages. |
**Stop on `X-Pagination-Page-Count`.** When `X-Pagination-Page` equals `X-Pagination-Page-Count`, you've fetched the last accessible page. The `Item-Count` can exceed `Page-Count × Limit` if the underlying result set is larger than the 20-page cap allows — `Page-Count` is the practical ceiling.
## A simple paginator
These snippets default to a conservative `limit=50` that fits inside every paginated endpoint's cap. Bump to `60` if you're hitting a genre or premieres endpoint and want bigger pages.
```js Node theme={"theme":{"light":"github-light","dark":"vesper"}}
async function* paginate(url, headers, limit = 50) {
let page = 1;
while (true) {
const res = await fetch(`${url}?page=${page}&limit=${limit}`, { headers });
yield await res.json();
const totalPages = Number(res.headers.get('X-Pagination-Page-Count'));
if (page >= totalPages) return;
page++;
}
}
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests
def paginate(url, headers, limit=50):
page = 1
while True:
res = requests.get(url, params={'page': page, 'limit': limit}, headers=headers)
yield res.json()
total_pages = int(res.headers.get('X-Pagination-Page-Count', '1'))
if page >= total_pages:
return
page += 1
```
**The hard page cap is 20.** No matter how many results match, you can never fetch beyond page 20 — that's `20 × max_limit = 1,200` items in the best case. If you need to walk a larger result set, narrow the query with the endpoint's own filter parameters (genre, year, country, network, sort) rather than paging deeper.
# Standard media objects
Source: https://api.simkl.org/conventions/standard-media-objects
The shape of Movie, Show, Anime, and Episode objects every endpoint accepts and returns.
Every endpoint that talks about a movie, show, anime, or episode uses the same JSON shapes. Learn them once and you can read or write any endpoint.
## The `ids` object
The `ids` object is the heart of every media object. It's how Simkl identifies what you mean across all the catalogs you can integrate with. Pass as many IDs as you have — Simkl resolves to the canonical record and returns the rest.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"ids": {
"simkl": 53536,
"imdb": "tt0181852",
"tmdb": 296
}
}
```
### Supported ID keys
**Read either `ids.simkl` or `ids.simkl_id` — same integer, two key names.** Depending on the endpoint, Simkl's canonical catalog ID comes back as `ids.simkl` or `ids.simkl_id`. Robust reader code accepts both:
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const id = item.ids.simkl ?? item.ids.simkl_id;
```
The integer is **globally unique and permanent** — use it as your primary key for caching and cross-referencing.
**`slug` is NOT unique** — multiple titles can share one (e.g. three different *Superman* movies live at `/movies/2059585/superman`, `/movies/260740/superman`, `/movies/951252/superman`). The slug is a URL hint only; never identify a record by slug alone.
**External IDs in `ids` (`imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …) are echo-only.** To resolve one to a Simkl record, use [`/redirect`](/api-reference/simkl/redirect) — it returns the canonical Simkl URL with the `simkl` ID baked in.
**`tmdb` is the one external ID that isn't globally unique** — TMDB keeps separate sequences for `movie` and `tv`, so the same numeric `tmdb` value can refer to either. When resolving a TMDB ID via [`/redirect`](/api-reference/simkl/redirect), always pass `type=movie` or `type=tv` alongside `tmdb`. When reading TMDB IDs back from responses, treat the container key (`movies` / `shows` / `anime`) as part of the ID's identity.
| Key | Type | Example |
| ------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `simkl` | integer | `53536` — Simkl's canonical ID. **Globally unique and permanent.** |
| `imdb` | string | `tt0181852` |
| `tmdb` | integer | `296` — **Not unique across types**; always pair with `type=movie` or `type=tv` when resolving via `/redirect`. TMDB has no anime type; anime is filed under `tv` on TMDB. |
| `tvdb` | int / string | `153021` or `the-walking-dead` |
| `mal` | integer | `4246` |
| `anidb` | integer | `10846` |
| `anilist` | integer | `21` |
| `kitsu` | integer | `12` |
| `anisearch` | integer | `2227` |
| `animeplanet` | string | `one-piece` |
| `livechart` | integer | `321` |
| `letterboxd` | string | `the-truman-show` |
| `netflix` | integer | `70210890` |
| `traktslug` | string | `john-wick-chapter-4-2023` |
| `crunchyroll` | integer | `656647` (episode-level) |
| `hulu` | integer | `681868` (episode-level) |
**Send every identifier you have — `title`, `year`, and the full `ids` object.**
Simkl walks the `ids` object in priority order (`simkl` first when present, then external IDs like `imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …). If no ID resolves, it falls back to a `title` + `year` match, then to title-only as a last resort. Sending everything you know — title, year, plus every external ID your client has cached — maximizes the chance the right item gets credited. Extra fields are free; missing fields can cause a `404 id_err` or, worse, a silent mismatch.
**You don't need to search before writing.** Endpoints like `/scrobble/*`, `/sync/history`, `/sync/add-to-list`, and `/sync/ratings` resolve IDs server-side — pass whatever you have directly, no `/search/*` round-trip required. Calling `/search/id` first to "resolve" a Simkl ID is wasted work that doubles your API quota for no gain.
See [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list.
## Movie
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "Terminator 3: Rise of the Machines",
"year": 2003,
"ids": {
"simkl": 53536,
"imdb": "tt0181852",
"tmdb": 296
}
}
```
When `simkl` is known, the minimum is just:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "ids": { "simkl": 53536 } }
```
## Show
A show object can include nested seasons and episodes for partial sync:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "The Walking Dead",
"year": 2010,
"ids": {
"simkl": 2090,
"tvdb": 153021,
"imdb": "tt1520211"
},
"seasons": [
{
"number": 1,
"episodes": [
{ "number": 1 },
{ "number": 2 }
]
}
]
}
```
## Anime
Same JSON shape as Show, plus `anime_type` and an optional flat `episodes` list. Simkl uses AniDB as its primary metadata source — episodes follow the **anime-native model** by default (each cour is its own title, episodes restart at 1). Simkl also accepts Western TVDB/TMDB-style `season` + `number` coordinates and cross-maps both to the same canonical episode.
Two integration paths (TMDB/TVDB-primary with `use_tvdb_anime_seasons`, or anime-native with AniDB/MAL/AniList/Kitsu IDs), the cross-mapping rules, write/read recipes, and edge cases like Attack on Titan and Solo Leveling.
**Anime can go under either `shows[]` / `show` or `anime[]` / `anime`.** Simkl resolves items by their `ids` first, then routes to the correct catalog — the key you pick is for clarity, not routing. Match the field to your data type when known; fall back to `shows` / `shows[]` when you only have TMDB / TVDB IDs.
| Endpoint family | Accepted top-level keys |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [`/scrobble/start`](/api-reference/simkl/scrobble-start), [`/pause`](/api-reference/simkl/scrobble-pause), [`/stop`](/api-reference/simkl/scrobble-stop), [`/checkin`](/api-reference/simkl/scrobble-checkin) | `movie`, `show`, `anime`, `episode` *(singular objects, one per request)* |
| [`/sync/history`](/api-reference/simkl/add-to-history), [`/sync/history/remove`](/api-reference/simkl/remove-from-history), [`/sync/add-to-list`](/api-reference/simkl/add-to-list), [`/sync/ratings`](/api-reference/simkl/add-ratings), [`/sync/ratings/remove`](/api-reference/simkl/remove-ratings) | `movies[]`, `shows[]`, `anime[]`, `episodes[]` *(plural arrays, batched)* |
| [`/sync/watched`](/api-reference/simkl/get-watched) | top-level array of items, no wrapping key |
**Anime-only IDs work anywhere** — `anidb`, `mal`, `anilist`, `kitsu`, `anisearch`, `animeplanet`, `livechart` inside `ids` resolve correctly whether the wrapper is `shows[]` or `anime[]`. For example, either of these adds Attack on Titan:
```json POST /sync/history — either form works theme={"theme":{"light":"github-light","dark":"vesper"}}
{ "anime": [ { "ids": { "anidb": 9541 } } ] }
{ "shows": [ { "ids": { "anidb": 9541 } } ] }
```
**Caveat: `not_found` always buckets under `shows`.** If a write fails to resolve, the response's `not_found.shows` array carries the failed entry regardless of whether you sent it under `shows[]` or `anime[]`. There is no `not_found.anime` bucket.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"title": "Attack on Titan",
"year": 2013,
"anime_type": "tv",
"ids": {
"simkl": 39687,
"mal": 16498,
"tvdb": 267440,
"imdb": "tt2560140",
"anidb": 9541
},
"episodes": [
{ "number": 1 },
{ "number": 2 }
]
}
```
### `anime_type`
Anime items include an `anime_type` field with one of these values:
* `tv`
* `special`
* `ova`
* `movie`
* `music video`
* `ona`
## Episode
Inside a `seasons[].episodes[]` array (or a flat `episodes` array), an episode is identified by `season` + `number`, **or** directly by `ids`. Optionally include `watched_at` to record when it was watched.
**Prefer `season` + `number` over episode IDs whenever you can.** The numeric `S1E4` coordinate is stable forever — it never changes once an episode airs. External episode IDs (TVDB / AniDB) get re-issued when the source catalog merges duplicates, re-numbers a season, or replaces a record, and **a stale episode ID returns `404`**. The `season` + `number` pair is the cheapest, most robust identifier for scrobble / sync writes — only fall back to `episode.ids` when your integration genuinely doesn't know which season/number it's dealing with (e.g. Plex-style scrapers that resolve a file to a single episode ID without season context).
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"watched_at": "2014-09-01T09:10:11Z",
"season": 1,
"number": 4
}
```
If you only have an episode ID (no season/number), pass it via `ids`. For episode-ID lookups in scrobble / sync writes, Simkl accepts **only `tvdb` and `anidb`** at the episode level. If both are sent, `tvdb` is tried first. Episode-level `imdb` and `tmdb` IDs do **not** exist on Simkl — those external services only assign IDs at the show/movie level.
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"watched_at": "2014-09-01T09:10:11Z",
"ids": {
"tvdb": 4274616,
"anidb": 142278
}
}
```
Catalog responses (e.g. `/tv/episodes/{id}`) may **echo** additional episode-level keys like `simkl` (Simkl's internal episode ID), `hulu`, or `crunchyroll`. Those are informational — they're returned for cross-reference but are not accepted for episode-ID lookup. Always send `season` + `number` (preferred) or `tvdb` / `anidb` when identifying an episode in a request.
## Tips for sending media objects
`title`, `year`, and any IDs you have. Simkl uses all of it to disambiguate. If two movies share a title and year, IDs save the day.
If you send `simkl: 53536` and `imdb: "tt9999999"` and they disagree, Simkl uses the `simkl` ID. Send only one source of truth when you can.
Simkl uses the anime-native model (each cour is its own title, episodes restart at 1) — same as AniDB / MAL / AniList / Kitsu / AniSearch / Anime-Planet / LiveChart / ANN. Western TV catalogs (TVDB / TMDB / IMDB) use the franchise-with-seasons model instead. The Scrobble and Sync endpoints accept both schemes and Simkl maps automatically — the response includes both `season`/`number` (anime-native) and `tvdb_season`/`tvdb_number` (Western style) for reference. See [Anime episode numbering](/guides/scrobble#anime-episode-numbering) for the full mapping rules.
# API Analytics
Source: https://api.simkl.org/debug/api-analytics
Live per-app request log — see exactly what your code sends to api.simkl.com, filter it, export it.
Every registered Simkl app has a built-in **Analytics view** with **up to 24 hours** of individual requests your app made against `api.simkl.com`, plus a **7-day rollup** of daily request totals. It's the fastest way to confirm what your code is actually sending — without setting up your own logger.
## Opening it
Open **[simkl.com/settings/developer/](https://simkl.com/settings/developer/)** while logged in. The page lists every app you've registered.
Click the app to land on its page (URL: `simkl.com/settings/developer/{APP_ID}/`). This is also where your `Client ID` and `Client Secret` live.
Click **Debug** (top-right of the app card). The analytics view opens in a new tab.
The page is opened via a short-lived signed link. The link **expires after 1 hour** — close and re-open the Debug link from your developer dashboard for a fresh session. The dashboard page is what authorizes you; the analytics URL itself isn't bookmarkable.
## What you can do here
* **Confirm your code is actually hitting Simkl.** Issue a request from your app, hit Refresh — if it doesn't appear, your client never reached us (wrong host, blocked by a proxy, or missing the [required headers](#headers-required-to-show-up) so we can't attribute it to your app).
* **Verify your sync loop matches the [two-phase pattern](/guides/sync) the Sync guide requires.** Apps that don't follow it download the whole user library every poll and get rate-limited — sometimes blocked. The Debug view makes both halves easy to audit:
1. Filter on `Path equals /sync/activities` — this is the cheap "did anything change?" check that has to fire **first** on every poll. If it isn't there, your loop is broken.
2. Filter on `Path contains /sync/all-items` and look at the **Query** column for every row. Each one should include `date_from=` so Simkl returns only the delta. A `/sync/all-items` row with no `date_from` means your client is asking for the whole library — fix it before it gets throttled.
* **See exactly what your OAuth library / SDK puts on the wire.** Wrappers and middleware obscure this; the Debug view shows the literal `client_id`, `app-name`, `app-version`, and `User-Agent` your library sent — useful for diagnosing `empty_field` and `grant_error` responses.
* **See the HTTP status Simkl returned, even when your client only shows a generic error.** Some HTTP libraries surface "Network error" or "Bad Request" without the actual status code. The **Edge** and **Origin** columns show `200` / `401` / `403` / `412` etc. for every request — pair the code with [/conventions/errors](/conventions/errors) to map it back to the meaning. Response bodies aren't recorded in the Debug view, but for `GET` requests the **Link** column re-fires the same URL in a new browser tab so you can read the live response body. `POST` / `PUT` / `DELETE` bodies still have to come from your own logging.
* **Spot retry loops, runaway timers, and duplicate calls.** The red **Burst** column and **High Traffic Burst Detected** banner surface "N requests in 1 second from the same IP" patterns automatically — find which path your code is over-firing before the auto-blocker does.
* **Tell edge cache hits from real backend responses.** When `Edge=200` and `Origin` is blank, the response came from the edge cache without touching the backend — useful for understanding why a stale value sometimes persists after a write.
* **Compare staging vs prod, or app A vs app B.** Filter on `User agent` or `Client IP` to slice traffic by deployment / machine and confirm each environment is sending what you think it is.
* **Export a CSV** of the current filtered view to drop into a spreadsheet, share with support, or attach to a bug report.
## Controls
| Control | What it does |
| ----------- | ------------------------------------------------------------ |
| **TIME** | Look-back window. Anywhere from `1h` to `24h`. Default `6h`. |
| **LIMIT** | Max events per load. Default `100`, max `1000`. |
| **Load** | Apply the current TIME + LIMIT and pull the events. |
| **Refresh** | Re-pull with the same settings. |
| **Copy ID** | Copies your `client_id` to the clipboard. |
| **Export** | Downloads the current (filtered) event list as CSV. |
## What each column shows
| Column | Meaning |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Time** | When the request hit Simkl. Defaults to **New York time** (EDT/EST); the column header shows the timezone label so you know what you're looking at. |
| **Burst** | Red `N` badge when N≥3 requests landed in the same second from the same IP. Single requests show nothing. |
| **Method** | `GET`, `POST`, etc. |
| **Scheme** | `https` (or `http` if your client is misconfigured). |
| **Path** | The endpoint hit. `/oauth/pin`, `/sync/all-items/...`, etc. |
| **Query** | Querystring as sent. Sensitive parameters (`code`, `token`, `access_token`, `refresh_token`, `redirect`, `redirect_uri`, `pin`) are **masked** as `***` so secrets don't show up if you screenshot the page. |
| **App Ver** | Value your client sent in the `app-version` query parameter. Column only appears if any visible event has it. |
| **Link** | Opens the full request URL in a new browser tab — re-fires the same `GET` against `api.simkl.com` and shows the response body inline. Handy for `GET` rows where you want to see the actual JSON your code received. (Doesn't replay POST / PUT / DELETE — the browser address bar can only issue GETs.) |
| **Edge** | HTTP status from Simkl's edge layer. |
| **Origin** | HTTP status from Simkl's backend. Usually the same as Edge; differs when the response was served from the edge cache without touching the backend (Edge `200`, Origin blank). |
| **Origin latency (ms)** | How long the backend took to produce the response, when measured. |
| **Cache status / Cache response** | Whether the request hit the edge cache, and what status it cached. |
| **Client IP** | A short opaque ID like `e-JgphVReMb9` — not your real IP. Same machine = same ID, so you can group "all the calls from this user" without exposing addresses. |
| **Country** | Where the request came from. |
| **User agent** | The `User-Agent` header your client sent — useful for telling staging and prod apart. |
## Burst detector
When 3+ requests from the same IP land in the same second, the page surfaces a red **High Traffic Burst Detected** banner at the top of the table plus a `X% BURST` badge near the event count. The Burst column highlights the specific rows that fired it.
The three usual culprits:
* **Retry loops without backoff** — fix with exponential backoff + jitter.
* **Parallel requests** — fire calls **sequentially**, awaiting each response before issuing the next. Don't `Promise.all` the same endpoint, don't let a new poll tick start before the previous one returned.
* **UI effects re-firing every render** — gate your `useEffect` / equivalent on a stable dependency.
Repeatedly tripping the banner can get your `client_id` throttled or temporarily blocked. Fix the loop before the auto-blocker fires — recovery isn't automatic.
## Filters
The **Filter results...** box does a free-text match across all visible columns.
**+ Add filter** opens a composer. Pick a column, an operator (`equals`, `not equals`, `contains`, `not contains`, `starts with`, `ends with`, `in`, `not in`), and a value. The value field autocompletes from what's actually in the data. Stack multiple filters — they AND together.
A few recipes:
* **Just failures** → `Origin contains 4` (catches 4xx)
* **One endpoint** → `Path equals /sync/history`
* **One country** → `Country equals US`
* **One machine** → `Client IP equals e-JgphVReMb9` (click the IP in any row to filter to its sibling requests)
**Exclude detail pages** is a one-click filter that hides per-record detail endpoints (typically the chattier ones), so the burst detector and the table focus on your sync/scrobble calls.
## Headers required to show up
**Requests without identifying parameters don't appear in your analytics.** Simkl matches events to your app by the `client_id` it sees in the request — so every API call has to include:
* `client_id` query parameter
* `app-name` and `app-version` query parameters
* `User-Agent` header in the form `my-app-name/1.0`
See [Headers](/conventions/headers) for the full reference. The minimum that works:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
```
Calls without these get attributed to the anonymous bucket and never surface in your Debug view.
## See also
Developer dashboard — register a new app or jump to your existing apps' Debug views.
The `client_id` / `app-name` / `app-version` / `User-Agent` reference.
Per-app daily quotas, per-IP throttling, the 20-second write lock.
What each error envelope means when the Debug view shows a 4xx / 5xx row.
# Handling anime
Source: https://api.simkl.org/guides/anime
Learn how to sync and interact with Simkl's anime catalog. Whether your app is TMDB-primary or Anime-native, this guide covers the mapping, payloads, and edge cases.
Simkl uses AniDB as its primary metadata source for anime. Because the anime catalog is modeled differently than Western TV shows (often split per-cour or numbered absolutely), Simkl provides an automated cross-mapping system. This allows developers to interact with the anime catalog using standard TVDB/TMDB season-and-episode coordinates.
This guide outlines the two primary integration paths, how the cross-mapping works under the hood with real-world examples, and how to write watch history and read catalog data.
***
### Recommendation: Adopt Simkl's Native Anime Catalog
If you are designing a new application or have the flexibility to add a dedicated **Anime** content type to your existing app, we strongly recommend using **Simkl's Native Anime Catalog (Path B)** instead of treating anime as TV Shows.
**Why this is better:**
* **No Season Discrepancies:** Completely bypasses TMDB vs. TVDB season boundary disagreements.
* **1:1 Native Syncing:** Matches the data shape of major anime trackers (MyAnimeList, AniList, Kitsu, AniDB) for seamless 1:1 cross-platform syncing.
* **Rich Metadata:** Access to anime-specific metadata like animation studios, airing calendars, and comprehensive franchise relationships (prequels, sequels, summaries, spin-offs).
***
## The Two Integration Paths
Choose the path that fits your app's existing metadata system:
Your app tracks general TV and uses TMDB or TVDB IDs. You want to sync anime without changing your TV-centric seasons/episodes structure.
*Uses Simkl's automatic cross-mapping via the `use_tvdb_anime_seasons` flag.*
Your app is dedicated to anime and uses MAL, AniDB, AniList, or Kitsu IDs. You deal with flat/absolute episode numbers directly.
*Bypasses cross-mapping and interacts directly with Simkl's anime endpoints.*
***
## Integration Guide by App Type
Different app types require different integration strategies. Check the recommendations below to see which path and endpoints you should focus on:
**Recommendation:** Use **Path A** (TMDB/TVDB-Primary) for convenience, but see the best practices below for handling TMDB-primary boundary issues.
* **Syncing lists:** Send history updates using TMDB or TVDB IDs, always passing `"use_tvdb_anime_seasons": true`.
* **Fetching library:** Query `GET /sync/all-items/anime?extended=full_anime_seasons`. Use the `mapped_tvdb_seasons` array to know which seasons to render, and map the episodes back to your UI using `tvdb.season` and `tvdb.episode`.
* **Detail views:** Use [`GET /anime/{id}`](/api-reference/simkl/get-anime) to fetch details like `studios` and `relations` for anime shows.
**Recommendation:** Use **Path A** (TMDB/TVDB coordinates) for automated matching.
* **Real-time scrobbling:** Send playback state to [`POST /scrobble/start`](/api-reference/simkl/scrobble-start) using standard TVDB or TMDB IDs under the **`show`** key.
* **Why this is easy:** You do not need to convert TVDB season/episodes to absolute numbers client-side. Simkl's scrobbler handles the translation to absolute/cour-split anime entries automatically.
* **File-based scrobbling:** If your scrobbler parses absolute numbers directly from filenames, you can optionally scrobble using absolute numbers under the `anime` object.
**Recommendation:** Use **Path B** (Anime-Native IDs).
* **Syncing lists:** Send history updates using native database IDs (`mal`, `anilist`, `anidb`, `kitsu`) under the **`anime`** key.
* **Absolute numbering:** Send flat, absolute episode numbers directly. Do not supply season coordinates.
***
## Best Practices for TMDB-Primary Applications
Since **TMDB** is the most common metadata source for general media tracking applications, Simkl supports it fully. However, because TMDB's season/episode definitions occasionally disagree with TVDB's (which Simkl uses for mapping coordinates), you should use the following safe integration workflows:
### The Safe Integration Workflow
If your application's primary metadata source is TMDB, follow these rules to ensure perfect matching:
1. **Send Both IDs:** Whenever writing to Simkl, include both the `tmdb` ID and the `tvdb` ID in your `ids` object. Simkl will resolve the show using the most reliable record.
2. **Translate to TVDB Coordinates on Writes:** If you know TMDB and TVDB disagree on season boundaries for a specific anime, prioritize sending **TVDB season and episode coordinates** in the write payload when `use_tvdb_anime_seasons: true` is set.
3. **Use Simkl's Native IDs as Fallbacks:** If a show's TVDB/TMDB seasons are severely misaligned or fragmented, look up the show on Simkl using Title + Year or external anime IDs (MAL/AniList), and sync it using **Path B** instead.
***
## How the Cross-Mapping Works
Simkl stores a **per-episode TVDB coordinate** (`tvdb.season`, `tvdb.episode`) on every anime episode in the catalog. That coordinate maps directly from any TVDB- or TMDB-shaped `seasons[N].episodes[M]` payload to the exact Simkl record + episode the user means. The mapping handles every catalog shape uniformly.
### Real-World Mapping Examples
The main TV story is split into six separate Simkl titles (one per broadcast cour). Each title's episodes start at `1`, and carry the TVDB coordinate it maps to:
| Simkl cour-title | Eps | First episode → TVDB |
| ---------------------------------------------------------------------------------------------------------------- | --- | ----------------------- |
| [AOT S1](https://simkl.com/anime/39687/shingeki-no-kyojin) (`39687`, 2013) | 25 | Simkl ep 1 → TVDB S1E1 |
| [AOT Season 2](https://simkl.com/anime/439744/shingeki-no-kyojin-season-2) (`439744`, 2017) | 12 | Simkl ep 1 → TVDB S2E1 |
| [AOT Season 3 part 1](https://simkl.com/anime/694485/shingeki-no-kyojin-season-3) (`694485`, 2018) | 12 | Simkl ep 1 → TVDB S3E1 |
| [AOT Season 3 part 2](https://simkl.com/anime/931899/shingeki-no-kyojin-season-3) (`931899`, 2019) | 10 | Simkl ep 1 → TVDB S3E13 |
| [AOT Final Season](https://simkl.com/anime/1120029/shingeki-no-kyojin-the-final-season) (`1120029`, 2020) | 16 | Simkl ep 1 → TVDB S4E1 |
| [AOT Final Season part 2](https://simkl.com/anime/1579947/shingeki-no-kyojin-the-final-season) (`1579947`, 2022) | 12 | Simkl ep 1 → TVDB S4E17 |
All six share TVDB ID `267440` and TMDB ID `1429`. When you write to `Season 3 Episode 13`, Simkl automatically uses the episode offset mapping to route the write to episode `1` of *AOT Season 3 Part 2* (`931899`).
#### Franchise Extensions (OVAs, Specials, Movies)
AniDB models specials, OVAs, films, and spin-offs as separate entries, which Simkl mirrors. Below is the inventory beyond the main TV cours:
| Simkl title | Type | Eps | TVDB cross-ref | TMDB cross-ref |
| --------------------------------------------------------------------------------- | --------- | --- | -------------------- | -------------------------- |
| [AOT The Final Season special](https://simkl.com/anime/1883416) (`1883416`, 2023) | `special` | 2 | `267440` (parent TV) | `1429` (parent TV) |
| [AOT: The Last Attack](https://simkl.com/anime/2544548) (`2544548`, 2024) | `movie` | 1 | — | `1333100` (dedicated film) |
| [AOT OAD](https://simkl.com/anime/38688) (`38688`, 2013) | `ova` | 8 | `267440` (parent TV) | `1429` (parent TV) |
| [AOT: No Regrets](https://simkl.com/anime/419356) (`419356`, 2014) | `ova` | 2 | — | — |
| [AOT: Wings of Freedom](https://simkl.com/anime/49394) (`49394`, 2014) | `movie` | 2 | `7398` (dedicated) | `1429` (parent TV) ⚠️ |
| [AOT: Roar of Awakening](https://simkl.com/anime/732309) (`732309`, 2018) | `movie` | 1 | `13398` (dedicated) | `1429` (parent TV) ⚠️ |
| [AOT: Chronicle](https://simkl.com/anime/1358719) (`1358719`, 2020) | `movie` | 1 | `140480` (dedicated) | `1429` (parent TV) ⚠️ |
| [AOT: Junior High](https://simkl.com/anime/513950) (`513950`, 2015) | `tv` | 12 | `299882` (dedicated) | `63510` (dedicated TV) |
*Note: ⚠️ entries indicate parent-show fallback links on TMDB/TVDB instead of dedicated ones.*
One Piece is represented as a single Simkl title (`38636`) with 1100+ episodes, numbered absolutely. Every episode carries its TVDB season + episode coordinate:
| Simkl episode (absolute) | TVDB coordinate | Title |
| ------------------------ | --------------- | ------------------------------------------------------ |
| 1 | S1E1 | I'm Luffy! The Man Who's Gonna Be King of the Pirates! |
| 9 | S2E1 | The Honorable Liar? Captain Usopp! |
| 31 | S3E1 | The Worst Man in the Eastern Seas! |
| 48 | S4E1 | The Town of the Beginning and the End! |
| 196 | S10E1 | A State of Emergency Is Issued! |
| 326 | S12E1 | The Mysterious Band of Pirates! |
| 878 | S20E1 | The World Is Stunned! The Fifth Emperor of the Sea! |
| 1156 | S23E1 | The Long-Sought Elbaph! |
The single Simkl title shares TVDB ID `81797` and TMDB ID `37854`. Writing to TVDB season `10` episode `4` maps to absolute episode `199` on the single One Piece Simkl record.
***
## Path A: TMDB or TVDB Primary Integration
If your app uses TMDB or TVDB show IDs, you do not need to convert seasons/episodes to absolute numbers client-side. Simkl maps them automatically on both writes and reads.
### 1. Writing to Simkl (Syncing history/list additions)
When marking an episode as watched or adding a show to a list, send your TMDB/TVDB show ID along with standard seasons and episodes, and set **`use_tvdb_anime_seasons: true`**.
Under the hood, this flag instructs the Simkl API to fetch the mapped franchise seasons/titles and route the TVDB coordinates to the correct under-the-hood Simkl anime record and episode.
#### Example Payload
For **Attack on Titan Season 2 Episode 4** (using parent TV show TMDB ID `1429`):
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "1429" },
"use_tvdb_anime_seasons": true,
"seasons": [
{
"number": 2,
"episodes": [{ "number": 4 }]
}
]
}
]
}
```
For **One Piece Season 10 Episode 4** (using TMDB ID `37854`):
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "37854" },
"use_tvdb_anime_seasons": true,
"seasons": [
{
"number": 10,
"episodes": [{ "number": 4 }]
}
]
}
]
}
```
**Pro-tip:** You can set `use_tvdb_anime_seasons: true` on all TV shows in your payload. For non-anime shows, the flag is a safe no-op. For anime, it automatically handles both per-cour split titles and absolute-numbered shows.
### 2. Reading from Simkl (Syncing watchlist & watch history)
When reading the user's library, use **`extended=full_anime_seasons`**. This includes the TVDB/TMDB season cross-mapping in the response.
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/all-items/anime?extended=full_anime_seasons&client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
#### Understanding the Response Fields
For anime shows, the response includes:
* **`mapped_tvdb_seasons`**: An array of TVDB season numbers covered by this Simkl title. (e.g., `[2]` for *Attack on Titan Season 2*).
* **`tvdb: { season, episode }`**: Attached to each episode object to map the AniDB/absolute episode back to the TMDB/TVDB coordinates.
Example response snippet:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"show": {
"title": "Shingeki no Kyojin Season 2",
"ids": { "simkl": 439744, "tmdb": "1429" }
},
"mapped_tvdb_seasons": [2],
"seasons": [
{
"number": 1,
"episodes": [
{
"number": 1,
"tvdb": { "season": 2, "episode": 1 }
}
]
}
]
}
]
}
```
### 3. Scrobbling Playback
For media players, scrobble payloads are single-item envelopes. You do **not** need to set `use_tvdb_anime_seasons`. Send the payload under the standard **`show`** key using your TMDB/TVDB coordinates, and Simkl will resolve the anime mapping automatically.
```json POST /scrobble/start theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"progress": 0,
"show": {
"ids": { "tmdb": "1429" }
},
"episode": {
"season": 2,
"number": 4
}
}
```
***
## Path B: Anime-Native Integration
If your app uses anime-native IDs (MAL, AniList, AniDB, Kitsu), you bypass Simkl's cross-mapping engine. External anime IDs have a 1:1 relationship with Simkl's anime records.
### 1. Writing to Simkl
Use the **`anime`** array envelope, and supply flat/absolute episode numbers under `episodes[]` (do not include a `season` field).
#### Example Payload: Cour-Split Anime (Attack on Titan S2 Ep 4)
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"ids": {
"mal": 25777,
"anilist": 20958,
"anidb": 10944
},
"episodes": [{ "number": 4 }]
}
]
}
```
#### Example Payload: Absolute-Numbered Anime (One Piece Absolute Ep 403)
```json POST /sync/history theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"ids": {
"mal": 21,
"anilist": 21,
"anidb": 69
},
"episodes": [{ "number": 403 }]
}
]
}
```
### 2. Reading from Simkl (Syncing watchlist & watch history)
When reading the user's library, perform a GET request to the anime-specific library endpoint:
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl "https://api.simkl.com/sync/all-items/anime?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
The response returns entries in the `anime` array, with all mapped external IDs (MAL, AniList, AniDB, Kitsu, etc.) populated in the `ids` block. You can map these IDs directly to your app's local database without any coordinate translation layers.
#### Example Response Snippet
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"anime": [
{
"added_to_watchlist_at": "2026-05-20T03:00:00-04:00",
"status": "watching",
"watched_episodes_count": 4,
"total_episodes_count": 12,
"anime": {
"title": "Shingeki no Kyojin Season 2",
"ids": {
"simkl": 439744,
"mal": "25777",
"anilist": "20958",
"anidb": "10944",
"kitsu": "8756"
}
}
}
]
}
```
***
## Anime-Specific Catalog Fields
When querying Simkl's catalog endpoints (e.g., [`GET /anime/{id}`](/api-reference/simkl/get-anime)), you will encounter fields unique to the anime catalog:
### 1. `anime_type`
Differentiates the format of the entry. Useful for client-side filtering:
| Type | Meaning |
| ------------- | ------------------------------------------ |
| `tv` | Standard broadcast television series |
| `movie` | Theatrical film |
| `ova` | Original Video Animation (direct-to-video) |
| `ona` | Original Net Animation (web-released) |
| `special` | Specials, OADs, side stories |
| `music video` | Music videos |
### 2. `relations[]`
Lists prequels, sequels, summaries, and side-stories.
* `relation_type`: Describes the relationship (e.g., `prequel`, `sequel`, `summary`, `side story`).
* `is_direct`: `true` for immediate narrative continuations; `false` for spin-offs or wider connections.
### 3. Flat Episode Lists
Unlike standard TV episode lists, [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) returns a flat array of episodes. Standard episodes and specials alike omit the `season` field, carrying only `episode` (flat/absolute number) and a `tvdb` coordinate block.
***
## Anime-Specific Endpoints + Parameters Reference
### Writes
| Endpoint | Anime-specific behavior |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`POST /sync/history`](/api-reference/simkl/add-to-history) | Accepts `anime[]` envelope (alternative to `shows[]`). The `use_tvdb_anime_seasons: true` flag on a `shows[]` entry routes TVDB season/episode coordinates to the correct under-the-hood Simkl record via per-episode cross-mapping. |
| [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history) | Same shape and flags as `/sync/history`. |
| [`POST /sync/add-to-list`](/api-reference/simkl/add-to-list) | Same shape and flags. Used to move anime into `watching` / `plantowatch` / `hold` / `completed` / `dropped`. |
| [`POST /scrobble/start`](/api-reference/simkl/scrobble-start) | Single-item envelope: `anime: { ids: {...} }` + `episode: { season, number }` (for TMDB/TVDB IDs) or `episode: { number }` (for anime-native IDs). Resolves the title and matches the episode through the same cross-map. |
### Reads — User Library
| Endpoint | Anime-specific behavior |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`GET /sync/all-items/anime`](/api-reference/simkl/get-all-items) | Type-scoped library read — returns only the user's anime entries. |
| [`GET /sync/all-items?extended=full_anime_seasons`](/api-reference/simkl/get-all-items) | Adds `mapped_tvdb_seasons: [N, ...]` to anime show entries and adds a `tvdb: { season, episode }` cross-map block to every anime episode in the response. |
| [`GET /sync/all-items?anime_type=tv`](/api-reference/simkl/get-all-items) | Filter anime entries by `anime_type` (`tv`, `movie`, `ova`, `ona`, `special`, `music video`). |
| [`GET /sync/activities`](/api-reference/simkl/get-activities) | Returns an `anime` block separate from `tv_shows` and `movies`, each with its own per-status timestamps. |
### Reads — Catalog
| Endpoint | Anime-specific behavior |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`GET /anime/{id}`](/api-reference/simkl/get-anime) | Anime detail. Returns: `anime_type`, `mapped_tvdb_seasons[]`, `relations[]`, `en_title`, `alt_titles[]`, `season_name_year`, and `studios`. (Does not include the episode list). |
| [`GET /anime/episodes/{id}`](/api-reference/simkl/get-anime-episodes) | Returns flat episode array (no `season` fields; `episode` number is 1-N). Includes `tvdb: { season, episode }` blocks on each episode. |
| [`GET /search/anime`](/api-reference/simkl/search-by-text) | Text search scoped to anime catalog. Returns `anime_type`, `title_romaji`, and `title_en`. |
***
## Integration Edge Cases
Because TMDB and TVDB are community-maintained, they occasionally disagree on season numbering for newer or split-cour anime (e.g., *Solo Leveling*).
Since Simkl's cross-mapping is based on **TVDB season coordinates**, sending TMDB-only IDs with TMDB-style season numbers can sometimes route to the wrong cour.
**Solutions:**
1. **Send the TVDB ID** (`ids.tvdb`) alongside the TMDB ID, using the TVDB season numbering in your payload.
2. **Provide anime-native IDs** (`mal`, `anilist`, `anidb`) if your app has access to them, which maps 1:1 and avoids the translation step.
3. **Send Title + Year** (`title` and `year`). Simkl's server-side title resolver is robust and resolves directly to the right cour.
OVAs, specials, and recap films often inherit the parent TV show's TMDB or TVDB ID on Simkl because a dedicated film entry does not exist or wasn't linked.
* **Implied Action:** Do not assume the Simkl `tmdb` / `tvdb` ID maps 1:1 to a unique external entry. To deep-link to external catalogs, treat these IDs as references rather than canonical links.
Some multi-part film series are grouped as a single Simkl entry with multiple episodes (each representing a film part).
Check the `total_episodes` field to determine if a `movie` entry contains multiple parts.
***
## Next Steps
`POST /sync/history` — payload shape, batching, mixing movies / shows / anime in one call.
Initial sync + continuous deltas, `/sync/activities` gating, `extended=full_anime_seasons`, supported IDs, write operations, edge cases.
Real-time start / pause / stop / checkin. Includes anime-episode-numbering examples for both anime-native and TMDB / TVDB-shaped scrobbles.
The `ids` table, `anime[]` vs `shows[]` envelope rule, and how episode objects work across every endpoint.
# Deep-linking to Simkl
Source: https://api.simkl.org/guides/deep-linking
Link from your app, newsletter, or extension straight to the right Simkl page — even when you don't have a Simkl ID. Use external IDs (IMDb, TMDB, MAL, …) or title + year as the entry point.
If your app already has external IDs (IMDb, TMDB, TVDB, AniDB, …) — typical for watchlist importers, media-server plugins (Plex / Jellyfin / Kodi), browser extensions, and any app that maintains its own catalog of titles — you don't need to call the Simkl JSON API just to send a user to the matching Simkl page. The `GET /redirect` endpoint takes any identifier you have and 301-redirects to the right URL on simkl.com.
**No `Authorization` token required.** No JSON parsing. The browser (or any HTTP client) follows the redirect automatically.
Two use cases (linking + ID resolution), every accepted parameter, all action modes (`to=simkl`/`trailer`/`twitter`/`watched`), curl/JS/Python recipes for resolving an external ID into a Simkl ID for follow-up API calls.
## The simplest case — just use the URL as a hyperlink
In a newsletter, a browser-extension menu, a "View on Simkl" button, or any other surface where you want a clickable link, **the `/redirect` URL is the href.** The browser hits it, gets a 301, follows it to simkl.com. No JS, no fetch, no parsing.
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
View Game of Thrones on Simkl
```
That's the entire integration. Just remember to URL-encode any query-string values that need it (titles with spaces, special characters).
## Recipes by what you have
**Pass every identifier you have — not just the cheapest one.** `/redirect` resolves the best match by considering all the data you send. Combining an ID with `title` and `year` is more reliable than the ID alone: if a catalog ever re-issues or merges an ID, the title and year keep your link pointing at the right item. The examples below show realistic combined payloads, not minimum-required calls.
```bash IMDb + title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
# IMDb IDs are unambiguous across types (the `tt` prefix is unique), so no `type` needed.
# Title and year are extra signal that helps when an ID is ambiguous or stale.
curl -I "https://api.simkl.com/redirect?to=simkl\
&imdb=tt0944947&title=Game%20of%20Thrones&year=2011\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Response:
# HTTP/1.1 301 Moved Permanently
# Location: https://simkl.com/tv/17465/game-of-thrones
```
```bash TMDB (TV) + title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
# TMDB IDs are NOT unique across types — a TMDB movie ID and TMDB TV ID can collide.
# Always pair TMDB with `type=movie` or `type=tv` — TMDB has no anime type
# (anime shows are filed under tv on TMDB; Simkl routes them to its anime catalog automatically).
curl -I "https://api.simkl.com/redirect?to=simkl\
&type=tv&tmdb=1399&title=Game%20of%20Thrones&year=2011\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/tv/17465/game-of-thrones
```
```bash TMDB (movie) + title + year → Simkl page theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -I "https://api.simkl.com/redirect?to=simkl\
&type=movie&tmdb=27205&title=Inception&year=2010\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/movies/472214/inception
```
```bash MAL + title + year → Simkl anime page theme={"theme":{"light":"github-light","dark":"vesper"}}
# Anime catalog IDs (MAL, AniDB, AniList, Kitsu, AniSearch, LiveChart, Anime-Planet)
# don't need `type` — they're unambiguous.
curl -I "https://api.simkl.com/redirect?to=simkl\
&mal=11757&title=Sword%20Art%20Online&year=2012\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/anime/37226/sword-art-online
```
```bash Multiple IDs at once → most reliable theme={"theme":{"light":"github-light","dark":"vesper"}}
# When your local catalog stores several external IDs per title (common for
# apps consolidating data from multiple databases), pass them all.
curl -I "https://api.simkl.com/redirect?to=simkl\
&imdb=tt0903747&tmdb=1396&tvdb=81189\
&title=Breaking%20Bad&year=2008\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/tv/11121/breaking-bad
```
```bash Title + year only → fallback when you have no IDs theme={"theme":{"light":"github-light","dark":"vesper"}}
# Useful for free-text catalogs or rare items not in mainstream databases.
# Always pair with `type` to disambiguate movies from same-titled shows.
curl -I "https://api.simkl.com/redirect?to=simkl\
&type=movie&title=Inception&year=2010\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/movies/472214/inception
```
```bash Specific episode → Simkl episode page theme={"theme":{"light":"github-light","dark":"vesper"}}
# Add `season=N&episode=M` to any of the above. Setting either parameter
# excludes movies from the candidate set automatically.
curl -I "https://api.simkl.com/redirect?to=simkl\
&imdb=tt0944947&title=Game%20of%20Thrones&year=2011\
&season=1&episode=3\
&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "User-Agent: my-app-name/1.0"
# Location: https://simkl.com/tv/17465/game-of-thrones/season-1/episode-3
```
## Action modes — what should clicking the link do?
The `to=` parameter controls what the redirect lands on. Default is `simkl` (the item's detail page).
| `to=` value | Where the user lands |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `simkl` *(default)* | The Simkl page for the item (or season / episode if you also passed `season`/`episode`). |
| `trailer` | The trailer URL (typically YouTube). Great for "Watch trailer" buttons. |
| `twitter` | A pre-filled tweet (`twitter.com/intent/tweet?...`) with the title and a Simkl link. Use for "Share on Twitter" / "Share on X" buttons. |
| `watched` | Marks the item as watched on the user's account, then lands them on the page. If the user isn't signed in, Simkl first redirects through `/oauth/authorize`, completes the action, then lands them on the page. |
```html theme={"theme":{"light":"github-light","dark":"vesper"}}
▶ Watch trailer
Share on X
✓ Mark as watched on Simkl
```
## When you already have a Simkl ID
If you've already called `/movies/:id`, `/tv/:id`, `/anime/:id`, or any sync endpoint, the response includes `ids.simkl_id` (or `ids.simkl`) and `ids.slug` on every item. **Build the URL yourself** — it's faster than another `/redirect` round-trip:
| Resource | URL pattern |
| ------------- | --------------------------------------------------------------- |
| Movie | `https://simkl.com/movies/{simkl_id}/{slug}` |
| TV show | `https://simkl.com/tv/{simkl_id}/{slug}` |
| Anime | `https://simkl.com/anime/{simkl_id}/{slug}` |
| TV season | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}` |
| TV episode | `https://simkl.com/tv/{simkl_id}/{slug}/season-{N}/episode-{M}` |
| Anime episode | `https://simkl.com/anime/{simkl_id}/{slug}/episode-{M}` |
| User profile | `https://simkl.com/{username}` |
| User stats | `https://simkl.com/{username}/stats/` |
| User library | `https://simkl.com/{username}/{tv\|movies\|anime}/` |
**Always include the slug** when you have it. The slug is technically optional, but skipping it forces Simkl to run a title lookup and 301-redirect to the slugged URL anyway — that's a wasted round-trip on both sides. The slug is returned in `ids.slug` on every standard media object — store it alongside the Simkl ID and reuse.
## Need the Simkl ID for an API call?
`/redirect` is also the **cheapest way** to resolve an external ID into a Simkl ID — much lighter than `GET /search/id`. Issue a HEAD request, parse the `Location` header for the numeric ID, then call `/movies/{id}` / `/tv/{id}` / `/anime/{id}` to get the full record. The follow-up call is Cloudflare-cached by Simkl ID, so repeat lookups of the same title cost almost nothing.
Step-by-step recipes in bash, JS, and Python for resolving an external ID into a Simkl ID via `/redirect`'s `Location` header, then fetching the full record from the cached detail endpoint. Includes the regex for extracting the ID and a caching recommendation.
## Caching links
The `/redirect` response always returns `Cache-Control: no-store`, so don't try to HTTP-cache the redirect itself. But **the resolved URL** (everything after the 301) doesn't change unless a Simkl admin re-slugs the title — extremely rare. If your app does the resolution once and renders many links to the same item, cache the result locally and skip the redirect for the next render.
## Required parameters
Like every Simkl endpoint, requests to `/redirect` must include `client_id`, `app-name`, `app-version` as URL parameters and a `User-Agent` header. See [Headers and required parameters](/conventions/headers#required-url-parameters) for the full convention.
`/redirect` does **not** require an `Authorization` token — except for `to=watched`, which signs the user in if they aren't already.
## Reference
The full API reference — every accepted parameter, all action modes, and recipes for the advanced "external ID → Simkl ID → detail-endpoint call" workflow.
Endpoint reference with the parameter schema and interactive playground.
Where the `ids.simkl_id` and `ids.slug` fields come from. You'll see them on every API response.
The complete list of external catalog identifiers and slugs you can pass to `/redirect`, with format examples for each.
# Mark as watched
Source: https://api.simkl.org/guides/mark-as-watched
The fastest way to record that a user finished a movie, episode, or season — without scrobbling.
If you just want to record that a user **finished** something — a movie, an episode, a whole season — use a single [`POST /sync/history`](/api-reference/simkl/add-to-history) call. You don't need scrobbling, and you don't need a media player.
**Use scrobble only if you're tracking real-time playback** (a media-server plugin, a video player). If you just want a "Mark as watched" button, this page is what you need.
## Endpoints used on this page
Mark one or many items as watched. The main endpoint for this guide.
Undo a mark-as-watched. Same payload shape.
For media players: report that playback has begun.
For media players: stop playback. ≥80% progress = marked watched.
## Mark a movie
```bash curl theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history?client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{
"movies": [
{ "ids": { "imdb": "tt1201607" } }
]
}'
```
```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}}
const params = new URLSearchParams({
client_id: clientId,
'app-name': 'my-app-name',
'app-version': '1.0',
});
await fetch(`https://api.simkl.com/sync/history?${params}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'my-app-name/1.0',
},
body: JSON.stringify({
movies: [{ ids: { imdb: 'tt1201607' } }],
}),
});
```
```python Python theme={"theme":{"light":"github-light","dark":"vesper"}}
import requests
requests.post(
'https://api.simkl.com/sync/history',
params={
'client_id': client_id,
'app-name': 'my-app-name',
'app-version': '1.0',
},
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
'User-Agent': 'my-app-name/1.0',
},
json={'movies': [{'ids': {'imdb': 'tt1201607'}}]},
)
```
You can pass any ID Simkl recognizes — see [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list with types and examples. Full payload reference at [`POST /sync/history`](/api-reference/simkl/add-to-history).
**User doesn't remember when they watched it?** POST `"watched_at": "1970-01-01T00:00:01Z"` — Simkl's *"Very long time ago / I don't remember"* placeholder. simkl.com's "When did you watch this?" date picker exposes this as a smart-suggestion card; clients should mirror that option. Full convention (including detection rule for reading and recommended date-picker UX patterns) at [Dates and timezones → "Very long time ago" placeholder](/conventions/dates#very-long-time-ago-placeholder).
**Send every identifier you have — `title`, `year`, and the full `ids` object.**
Simkl walks the `ids` object in priority order (`simkl` first when present, then external IDs like `imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …). If no ID resolves, it falls back to a `title` + `year` match, then to title-only as a last resort. Sending everything you know — title, year, plus every external ID your client has cached — maximizes the chance the right item gets credited. Extra fields are free; missing fields can cause a `404 id_err` or, worse, a silent mismatch.
**You don't need to search before writing.** Endpoints like `/scrobble/*`, `/sync/history`, `/sync/add-to-list`, and `/sync/ratings` resolve IDs server-side — pass whatever you have directly, no `/search/*` round-trip required. Calling `/search/id` first to "resolve" a Simkl ID is wasted work that doubles your API quota for no gain.
See [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list.
## Mark an episode
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "1399" },
"seasons": [
{
"number": 1,
"episodes": [{ "number": 1 }]
}
]
}
]
}
```
## Mark a whole season
Drop the `episodes` array — Simkl marks every episode in the listed season:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{
"ids": { "tmdb": "1399" },
"seasons": [{ "number": 1 }]
}
]
}
```
## Mark a whole show
Drop both `seasons` and `episodes`:
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"shows": [
{ "ids": { "tmdb": "1399" } }
]
}
```
## Mix everything in one call
Movies, shows, and anime can be sent together. Useful for importing watch history from another tracker. Anime entries are equally valid under either `shows[]` or `anime[]` — Simkl resolves the catalog by `ids` either way (see [Anime in `shows[]` or `anime[]`](/conventions/standard-media-objects#anime)).
```json theme={"theme":{"light":"github-light","dark":"vesper"}}
{
"movies": [
{ "ids": { "imdb": "tt1201607" } },
{ "ids": { "tmdb": "12445" } }
],
"shows": [
{ "ids": { "tvdb": "121361" } }
],
"anime": [
{ "ids": { "mal": "4246" } }
]
}
```
## When to use which endpoint
[`POST /sync/history`](/api-reference/simkl/add-to-history) — instant, one call. Best for "Mark watched" buttons, importers, manual logging.
Real-time playback tracking. Best for media players (Plex, Jellyfin, custom apps) that report actual user events.
**Scrobble does mark the item as watched, but only at the end of playback** — either when you call [`POST /scrobble/stop`](/api-reference/simkl/scrobble-stop) with ≥ 80% progress, or when [`POST /scrobble/checkin`](/api-reference/simkl/scrobble-checkin) auto-completes at 100% based on the item's runtime. **[`POST /scrobble/start`](/api-reference/simkl/scrobble-start) alone does *not* mark anything watched** — it only puts the title in the user's "Watching now" banner. If you're already scrobbling, you don't also need to call `/sync/history`.
## Record a rewatch (Simkl Pro / VIP)
A `POST /sync/history` call on an already-Completed item is normally a no-op. Pass `?allow_rewatch=yes` to record an additional viewing as a separate rewatch session — but **read the full [Rewatches guide](/guides/rewatches) first**. The flag opens a parallel write path that, used carelessly, pollutes the user's history stats and rewatches panel with phantom sessions. The guide covers session lifecycle (`active` / `completed` / `closed`), per-item fields (`rewatch_id`, `is_rewatch`, …), episode-level tracking on shows, reading sessions back from `GET /sync/all-items`, UI patterns simkl.com uses, and — critically — the precautions you have to implement before enabling the flag.
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history?allow_rewatch=yes&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{ "movies": [{ "ids": { "imdb": "tt1201607" }, "watched_at": "2026-05-10T20:00:00Z" }] }'
```
## Removing items
Made a mistake? Remove with the same payload shape, just hit [`POST /sync/history/remove`](/api-reference/simkl/remove-from-history):
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history/remove?client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{ "movies": [{ "ids": { "imdb": "tt1201607" } }] }'
```
**A few `POST /sync/history` behaviors worth knowing:**
* **No `seasons` / `episodes` and no `status` for a show.** Simkl picks based on the show's airing status: finished airing → **Completed** with every episode marked; currently airing → **Watching** with all *already-aired* episodes marked (future episodes left untouched); not yet released → **Watching** with no episodes marked.
* **Set status without touching episodes.** Pass `status: "watching"` (or any other status) on the show to move the watchlist bucket without auto-marking episodes.
* **Episode-level `ids` override `number`.** When an episode object includes `ids.tvdb` or `ids.anidb`, those IDs take precedence over the `number` field for matching — useful for absolute-order anime numbering or specials whose episode numbers don't line up across sources. That said, **prefer `season` + `number` when you have them** — episode IDs can be re-issued when catalogs merge or re-number, while `S1E4` is stable forever (see [Episode IDs](/conventions/standard-media-objects#episode)).
* **Skip the auto-fill.** Add `?skip_auto_watching=yes` to suppress the auto-mark behavior for shows posted without explicit episodes. Only takes effect when the request also sets an explicit `status` on the show.
# Rewatches
Source: https://api.simkl.org/guides/rewatches
Track separate viewing sessions for items the user already finished — mirror what simkl.com displays on a movie / show / anime detail page.
A **rewatch** is a separate viewing session for an item the user already completed. Each session has its own start date, episode progress (for shows / anime), and status (`active`, `completed`, or `closed`). Up to 50 sessions per item. Simkl returns the full session list so you can replicate the simkl.com "Rewatches" panel.
**Read this guide before enabling `?allow_rewatch=yes` in production.** Wired up carelessly — on retries, scrobble events, importer re-runs, or without pinning `rewatch_id` after the first write — the flag pollutes the user's history stats and rewatches panel with phantom sessions. Before flipping it on:
1. **Pin `rewatch_id`** on every write after the first. Without it, sessions can fork silently.
2. **Gate `?allow_rewatch=yes` behind explicit user intent** — a dedicated "Rewatch" button. Never on background syncs, scrobble auto-events, importer re-runs, or retries.
3. **Default off in your app settings.** Expose a per-user *"Track rewatches"* toggle — many users prefer plain "mark watched" without session bookkeeping.
4. **Test the full lifecycle** (start → episodes → close → reactivate → complete) on a real account before shipping.
If you just need to mark watched, plain `POST /sync/history` is what you want — see [Mark as watched](/guides/mark-as-watched).
**Simkl Pro / VIP only.** Free-tier callers with `?allow_rewatch=yes` get a silent no-op. If your UI exposes a "Rewatch" button to a free-tier user, disable it with a short note like *"Rewatch tracking requires Simkl Pro or VIP"* and link to [simkl.com/vip](https://simkl.com/vip/).
## Endpoints used
Write watch events. `?allow_rewatch=yes` makes the write land in a rewatch session instead of the canonical row.
Read sessions back. `?allow_rewatch=yes` adds one extra row per saved session alongside the canonical entry.
## Quick start
**A movie rewatch:**
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
curl -X POST "https://api.simkl.com/sync/history?allow_rewatch=yes&client_id=$CLIENT_ID&app-name=my-app-name&app-version=1.0" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-app-name/1.0" \
-d '{ "movies": [{ "ids": { "simkl": 53536 }, "watched_at": "2026-05-10T20:00:00Z" }] }'
```
**A TV episode rewatch — first call creates the session:**
```bash theme={"theme":{"light":"github-light","dark":"vesper"}}
POST /sync/history?allow_rewatch=yes
{ "shows": [{ "ids": { "simkl": 1234 }, "seasons": [{ "number": 1, "episodes": [{ "number": 1, "watched_at": "2026-05-10T20:00:00Z" }] }] }] }
```
Response echoes `rewatch_id` and `rewatch_status` inside `added.statuses[].response`. **Cache the `rewatch_id` and pin it on every subsequent write** for that session.
## When does a write become a rewatch?
The server always tries the regular write first. If the regular write would actually mark new data, that takes precedence — no rewatch is created. A write lands in a rewatch session when **any** of these holds:
1. **The item is already Completed and the regular add is a no-op** (movies: user already finished it; shows: payload has no episodes and the show is Completed).
2. **You included episodes and every one is already on the user's history** — i.e. the regular write would change nothing.
3. **You passed `is_rewatch: true`** on the item.
`is_rewatch: true` does **not** bypass the regular-first logic. If the write tries to add a date that already exists in canonical history, it's still rejected. The flag's main use is to create an empty rewatch session on a Completed item your UI explicitly marked as *"starting a rewatch"* — without writing any episodes yet.
The episode or movie must already exist on the user's history (`completed` / `watching` / `hold` / `dropped`) — Plan-to-Watch entries can't be rewatched, there's nothing to rewatch.
## Per-item rewatch fields
**All fields below are optional.** They only take effect when `?allow_rewatch=yes` is on the URL.
| Field | Type | Purpose |
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `is_rewatch` | bool | Trigger rewatch logic explicitly. See [When does a write become a rewatch?](#when-does-a-write-become-a-rewatch) for what overrides what. |
| `rewatch_id` | int | Resume a specific session. Use the value from a previous response. Without it, the server picks the item's active session if one exists, else creates a new session. |
| `rewatch_status` | string | Set session state directly: `active`, `completed`, or `closed`. Use to explicitly close, reactivate (the simkl.com "Reactivate" gesture), or force-complete. |
| `watched_at` | string (ISO-8601) | Standard per-item timestamp. Also serves as the session's start date. |
| `last_watched_at` | string (ISO-8601) | Primarily a **response** field from `GET /sync/all-items` (the session's most recent watch event). Rarely set on writes. |
## Session states
```
active ──── auto: all aired eps watched ────► completed
│
└──── manual: rewatch_status="closed" ────► closed
(with some episodes watched)
```
| State | Meaning | How it's set |
| ----------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `active` | In progress. Only one per item at a time. | Default for new TV / anime sessions; or `rewatch_status: "active"` to reactivate a closed / completed session. |
| `completed` | Done — every aired episode rewatched. | Auto when `watched_episodes_count >= aired_episode_count`. Default for new movie sessions (no episodes to track). Or `rewatch_status: "completed"`. |
| `closed` | The user closed the session. Resumable at any time. | `rewatch_status: "closed"`. Also set automatically on the previously-active session when another session is made active for the same item. Can be set on any session — including one with zero episodes watched (the simkl.com UI then shows it as completed, with no "partial" label). |
The flow is: a new session starts as `active`, then becomes either `completed` (all aired episodes watched) or `closed` (user ended the session before reaching the end). Manual transitions via `rewatch_status` can move a session between any states — that's how simkl.com's *"Reactivate this rewatch"* gesture works.
## The 2-day gap
Any two watch events on the **same item** (movie or individual episode) must be at least 2 days apart, or the second write collapses into the first session. *It's a rewatch, not a rewind* 😄.
The gap is **per-item**, not per-show. Watching S1E1 on Monday and S1E2 on Tuesday is fine — those are different items. The rule only fires when the *same* episode is written twice within 48h.
**Why a 2-day gap?** It absorbs common timestamping problems that would otherwise inflate the user's history with phantom rewatches:
* **Sleep-and-resume** — user starts an episode at night, finishes the next morning. One viewing, not two.
* **Wrong timezones** — clients labeling local time as UTC (or vice versa) drift 1–12 hours per write.
* **DST transitions** — naive date libraries miscalculate by an hour twice a year.
* **Multi-device duplicates** — phone + TV both report the same play.
* **Retry storms** — flaky connections retry a write that already landed.
* **Importer re-runs** — re-running an import re-sends the same `watched_at`.
* **Scrobble pause/resume noise** — bathroom break → re-fire of `/start` + `/stop`.
* **Buggy progress reporting** — players hitting 80%+ multiple times on seeks.
Real rewatches happen days, weeks, or months apart — not within hours. The 48-hour rule encodes that.
## Reading sessions back
`?allow_rewatch=yes` on `GET /sync/all-items` adds one extra entry per saved rewatch session alongside the canonical entry for each item. **Always pair with `date_from`** — never call without it outside the initial Phase-1 sync.
**`allow_rewatch=yes` alone returns summary-only rewatch rows.** `watched_episodes_count` returns `0` as a sentinel, `last_watched` is `null`, and `seasons[].episodes[]` is absent — even when the session actually has episodes recorded. To get per-episode data on rewatch rows you **must** add `extended=full` (and usually `episode_watched_at=yes` for timestamps):
```
GET /sync/all-items/all/completed?allow_rewatch=yes&extended=full&episode_watched_at=yes
```
Apps that mirror simkl.com's "Rewatches" panel — or that need to apply per-episode diffs from rewatch sessions to a local cache — should always use this flag combo. The [Flag combinations table](#flag-combinations-for-episode-lists) below has the full set.
### Canonical vs rewatch rows
| Field | Canonical (`is_rewatch: false`) | Rewatch (`is_rewatch: true`) |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `is_rewatch` | `false` *(emitted when `?allow_rewatch=yes` is set; otherwise absent)* | `true` |
| `rewatch_id` | absent | session id |
| `rewatch_status` | absent | `active` / `completed` / `closed` |
| `last_watched_at` | The canonical (original) last watch date for the item — **does not** reflect rewatch sessions. | This session's most-recent watch event. |
| `watched_episodes_count` | Episodes marked watched on the canonical row (e.g. `62/62` for a finished show). **Not** a lifetime total across rewatches. | Per-session count — **`0` as a sentinel without `extended=full`**. |
The canonical row and the rewatch rows are independent: `last_watched_at` and `watched_episodes_count` on the canonical entry never incorporate rewatch progress, no matter how many sessions exist for the item.
### Flag combinations for episode lists
| Flags (on top of `allow_rewatch=yes`) | What rewatch entries carry |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| (none) | Summary only. `watched_episodes_count` returns `0` as a **sentinel** even when the session actually has episodes. |
| `extended=full` | Summary fields **populated** with real per-session counts, plus `seasons[].episodes[]` listing episode `number`. |
| `extended=full_anime_seasons` | Same plus anime `tvdb` mapping per episode. |
| `extended=full` + `episode_watched_at=yes` | Same plus per-episode `watched_at` (from each episode's `completed_on`). |
`episode_watched_at=yes` is a **modifier** — it adds timestamps to episodes already loaded. Without `extended=full`, episodes aren't loaded and the flag has no effect on rewatch entries.
## UI patterns (mirror simkl.com)
Filter `?allow_rewatch=yes` response for the current item and count by `rewatch_status`. `closed` is the "partial" count; `completed` is the clean-finish count.
```js theme={"theme":{"light":"github-light","dark":"vesper"}}
const sessions = response.filter(r => r.is_rewatch && r.show.ids.simkl === currentId);
const completed = sessions.filter(s => s.rewatch_status === 'completed').length;
const closed = sessions.filter(s => s.rewatch_status === 'closed').length;
const lastDate = sessions.map(s => s.last_watched_at).sort().pop();
```
Find the `active` session and show `watched_episodes_count / total_episodes_count`. Number the badge by chronological index among all sessions for the item — the number isn't stored on the server, you compute it client-side.
Movies don't really have an active phase — every movie rewatch session flips to `completed` immediately.
Fetch the active session with `?allow_rewatch=yes&extended=full&date_from=`, walk `total_episodes_count`, find the first episode not in the session's `seasons[].episodes[]` list, and POST it pinned to the session's `rewatch_id`.
All three are `POST /sync/history?allow_rewatch=yes` with different combinations:
* **Reactivate an older session**: `rewatch_id: N, rewatch_status: "active"`. The previously-active session for that item auto-closes.
* **Start new**: write the first episode (or `is_rewatch: true` for an empty session on a Completed item).
* **Close**: `rewatch_id: N, rewatch_status: "closed"`. Keeps `watched_episodes_count`; can be resumed by another write to the same `rewatch_id`.
Filter rewatch entries, sort by `last_watched_at` ascending. Label sessions "Rewatch #1", "Rewatch #2", etc. on the client.
## Limits and rules
* **Maximum 50 rewatches per item** (movie, show, or anime — combined cap).
* **One `active` session per item.** Making another session active auto-closes the previous active one.
* **2-day minimum gap** between watch events on the same movie or episode. See [The 2-day gap](#the-2-day-gap).
* **Simkl Pro / VIP only.** Free-tier writes are silent no-ops.
## Edge cases
Returns `200 OK` with `added: { movies: 0, shows: 0, episodes: 0 }`. No error. Detect by checking the user's subscription tier client-side, or by inspecting the `added` counts in the response.
Subsequent attempts past 50 may start being rejected once server-side enforcement is added. Count sessions client-side before exposing a "Start new rewatch" button.
Starting a new active session auto-closes the previous one (its `watched_episodes_count` is preserved). The old session is still readable and still counts toward the 50-cap.
A rewatch write — including one with `is_rewatch: true` — is rejected if the `watched_at` date matches an existing canonical episode/movie row. Use a different `watched_at` value, or omit the conflicting episode.
Second write collapses into the same session. `watched_episodes_count` doesn't increment; `last_watched_at` updates to the latest value. Intentional — see [The 2-day gap](#the-2-day-gap).
If an episode is already in the active session but a new write for that same episode arrives with a `watched_at` more than 48h away, the server closes the current active session (preserving its `watched_episodes_count`) and opens a fresh active session with the conflicting episode at the new timestamp. Common with offline-capable apps catching up after a long sync gap.
When a rewatch session is created through the simkl.com web UI's "Start new rewatch" button, the server bulk-inserts episode rows for every episode the user had previously watched (a snapshot of their prior progress). Sessions created via plain API writes don't do this. Re-fetch `/sync/all-items` after a session was created on simkl.com to see what's actually there.
Intentional *"watched, date unknown / long ago"* placeholder — not a NULL or bug. See [Dates and timezones → "Very long time ago" placeholder](/conventions/dates#very-long-time-ago-placeholder).
## Reference
Two-phase model, `date_from` semantics, deletion reconciliation, edge cases.
Simple "mark watched" flow without rewatch sessions.
Endpoint reference + interactive playground.
Endpoint reference + interactive playground.
# Scrobbling playback
Source: https://api.simkl.org/guides/scrobble
Report real-time playback to Simkl with start, pause, stop, and checkin. Auto-mark items watched at 80% (stop) or 100% (checkin).
**Scrobbling** is how a media player reports real-time playback to Simkl. As the user starts, pauses, and finishes watching, your app sends events with the current `progress`. Simkl tracks the active session, shows the title in the user's "Watching now" banner, and marks the item watched once playback completes.
If you only need a "Mark as watched" button without real-time progress reporting, use [Mark as watched](/guides/mark-as-watched) instead.
## The lifecycle at a glance
Four endpoints. The 80% threshold is the only "magic number" you have to remember.
| Endpoint | Call when | Effect |
| ------------------------------------------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **[`start`](/api-reference/simkl/scrobble-start)** | Playback begins or resumes | Creates an active session and shows the title in the user's "Watching now" banner. |
| **[`pause`](/api-reference/simkl/scrobble-pause)** | User pauses | Saves current `progress`. The session is resumable from any device on the same account. |
| **[`stop`](/api-reference/simkl/scrobble-stop)** | User stops or playback ends | `progress ≥ 80` → marked **watched**. `progress < 80` → kept as a **saved playback** (resumable later). |
| **[`checkin`](/api-reference/simkl/scrobble-checkin)** | Fire-and-forget alternative | One call, no progress tracking. Server auto-completes shortly after the item's runtime expiry — typically within \~2 minutes. |
**Send every identifier you have — `title`, `year`, and the full `ids` object.**
Simkl walks the `ids` object in priority order (`simkl` first when present, then external IDs like `imdb`, `tmdb`, `tvdb`, `mal`, `anidb`, …). If no ID resolves, it falls back to a `title` + `year` match, then to title-only as a last resort. Sending everything you know — title, year, plus every external ID your client has cached — maximizes the chance the right item gets credited. Extra fields are free; missing fields can cause a `404 id_err` or, worse, a silent mismatch.
**You don't need to search before writing.** Endpoints like `/scrobble/*`, `/sync/history`, `/sync/add-to-list`, and `/sync/ratings` resolve IDs server-side — pass whatever you have directly, no `/search/*` round-trip required. Calling `/search/id` first to "resolve" a Simkl ID is wasted work that doubles your API quota for no gain.
See [Supported ID keys](/conventions/standard-media-objects#supported-id-keys) for the full list.
## Which pattern do I need?
```mermaid theme={"theme":{"light":"github-light","dark":"vesper"}}
flowchart TD
Q1{Does your player emit play / pause / stop events?}
Q1 -->|yes| Q2{Need live 'Watching now' plus cross-device resume?}
Q1 -->|no, fire-and-forget| CHECKIN[Use POST /scrobble/checkin]
Q2 -->|yes| LOOP[Use start / pause / stop loop]
Q2 -->|no, just credit a watch| HISTORY[Use POST /sync/history]
LOOP --> RESULT1[Live tracking + cross-device resume progress >= 80 auto-marks watched]
CHECKIN --> RESULT2[Lightweight; auto-completes at runtime expiry no follow-up calls needed]
HISTORY --> RESULT3[Just records the watch no live status see Mark-as-watched guide]
```
**Do not poll `/scrobble/*` periodically.** Send events **only on real user actions** — pressed Play, Paused, Stopped — with the current `progress` percentage. Simkl automatically advances progress between events using the item's known runtime, so periodic re-posting wastes API quota and triggers rate limits.
## Universal player-event mapping
This is the table to keep open while you're wiring your player. It's intentionally player-agnostic — the same mapping works for HTML5 `