# 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. **Migrate to the v2 files before 1 February 2027.** The older calendar files (the same paths **without** the `/v2/` segment) are deprecated. They keep regenerating until **1 February 2027**, after which they stop updating permanently and will serve stale data. Update your code to read from `https://data.simkl.in/calendar/v2/…` — the v2 payload is smaller, normalizes every timestamp to UTC, and ships far richer per-show metadata. **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 airings across hundreds of shows. * 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/v2/tv.json?client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://data.simkl.in/calendar/v2/tv.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | | Anime | [https://data.simkl.in/calendar/v2/anime.json?client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://data.simkl.in/calendar/v2/anime.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | | Movie releases | [https://data.simkl.in/calendar/v2/movie\_release.json?client\_id=YOUR\_CLIENT\_ID\&app-name=my-app-name\&app-version=1.0](https://data.simkl.in/calendar/v2/movie_release.json?client_id=YOUR_CLIENT_ID\&app-name=my-app-name\&app-version=1.0) | ### Monthly archives Three regeneration tiers: * **Current month + next 3 months ahead** — regenerated **every 6 hours**. Future-month files contain only what's already been scheduled or announced, so item counts taper off the further ahead you go. * **Previous 12 months** — regenerated **every 24 hours**. * **Older than 12 months** — archived in place. Files remain queryable indefinitely but are no longer regenerated; `Last-Modified` freezes at end-of-month. URL pattern: ``` https://data.simkl.in/calendar/v2/{YEAR}/{MONTH}/{tv|anime|movie_release}.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0 ``` Examples for the past month (`{{PAST_YEAR}}/{{PAST_MONTH}}`) and the furthest future month available right now (`{{FUTURE_YEAR}}/{{FUTURE_MONTH}}`, which is `currentMonth + 3`). **These dates substitute on page load** — they always reflect today: ``` # Past month (last month from today) https://data.simkl.in/calendar/v2/{{PAST_YEAR}}/{{PAST_MONTH}}/tv.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0 https://data.simkl.in/calendar/v2/{{PAST_YEAR}}/{{PAST_MONTH}}/anime.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0 https://data.simkl.in/calendar/v2/{{PAST_YEAR}}/{{PAST_MONTH}}/movie_release.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0 # Future month — currentMonth + 3 (furthest future month available; regenerated alongside the current month) https://data.simkl.in/calendar/v2/{{FUTURE_YEAR}}/{{FUTURE_MONTH}}/tv.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0 https://data.simkl.in/calendar/v2/{{FUTURE_YEAR}}/{{FUTURE_MONTH}}/anime.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0 https://data.simkl.in/calendar/v2/{{FUTURE_YEAR}}/{{FUTURE_MONTH}}/movie_release.json?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0 ``` Requesting any month beyond `currentMonth + 3` returns **404**. ## Response shape Every v2 file — rolling and monthly, all three catalogs — is a single object with exactly two keys: ```json theme={"theme":{"light":"github-light","dark":"vesper"}} { "calendar": [ /* one entry per airing / release, in chronological order */ ], "metadata": { /* keyed by simkl_id — one record per distinct show or movie */ } } ``` **`calendar` is the schedule. `metadata` is the show library.** A show airing 12 episodes this month appears **12 times** in `calendar` but only **once** in `metadata`. That's the whole point of the split: title, poster, IDs, ratings, and genres are stored once instead of being repeated on every airing. To render a row, take a `calendar` entry and look up its show: ```js theme={"theme":{"light":"github-light","dark":"vesper"}} const { calendar, metadata } = await res.json(); for (const entry of calendar) { const show = metadata[entry.simkl_id]; // join on simkl_id console.log(entry.date, show.title, entry.episode?.title); } ``` **`metadata` keys are strings.** It's a JSON object, so `simkl_id` becomes a string key. In JavaScript `metadata[entry.simkl_id]` works because JS coerces the number to a string on property access. In strongly-typed languages, convert explicitly — Python `metadata[str(entry["simkl_id"])]`, Swift `metadata[String(entry.simklId)]`, Kotlin `metadata[entry.simklId.toString()]`. Every `simkl_id` in `calendar` is guaranteed to have a matching `metadata` record, and there are no orphan metadata records — the two are always consistent within a single file. ### Sample (TV) ```json theme={"theme":{"light":"github-light","dark":"vesper"}} { "calendar": [ { "simkl_id": 3437, "date": "2026-07-20T04:00:00Z", "finale_type": null, "episode": { "season": 15, "episode": 1, "title": "Failure to Hard Launch", "url": "https://simkl.com/tv/3437/king-of-the-hill/season-15/episode-1/" } }, { "simkl_id": 3437, "date": "2026-07-27T04:00:00Z", "finale_type": 2, "episode": { "season": 15, "episode": 10, "title": "Propane Recall", "url": "https://simkl.com/tv/3437/king-of-the-hill/season-15/episode-10/" } } ], "metadata": { "3437": { "title": "King of the Hill", "url": "/tv/3437/king-of-the-hill", "alt_titles": [ { "name": "König von Texas", "lang": 9, "type": "official" } ], "poster": "12/12634613c2b41dc5a1", "fanart": "23/23782f1e28e9ca2", "ids": { "simkl_id": 3437, "slug": "king-of-the-hill", "imdb": "tt0118375", "tmdb": "1434", "tvdb": "73141" }, "release_date": "1997-01-12T15:30:00Z", "rank": 1820, "drop_rate": "8.5%", "watched": 24107, "plan_to_watch": 3391, "ratings": { "simkl": { "rating": 8.1, "votes": 942 }, "imdb": { "rating": 8.0, "votes": 104218 } }, "country": "us", "original_language": "en", "runtime": "22m", "status": "ongoing", "total_episodes": 275, "network": "Hulu", "genres": ["Animation", "Comedy"], "trailer": null } } } ``` ## `calendar[]` — the schedule Simkl ID of the show or movie. **Join key into `metadata`** (as a string). Air / release timestamp, **always UTC with a trailing `Z`** — e.g. `2026-07-20T04:00:00Z`. Convert to the user's local timezone for display. Entries are ordered chronologically. Marks finale episodes so you can badge them in the UI. `null` on ordinary episodes and on all movie entries. | Value | Meaning | | ------ | ----------------- | | `1` | Mid-season finale | | `2` | Season finale | | `3` | Series finale | | `null` | Not a finale | **TV and anime only — the key is absent entirely on movie files** (Type 2 null — see [Null and missing values](/conventions/null-values#type-2)). A movie release has no episode, so the whole object is omitted rather than set to `null`. Season number. **Present on TV; absent on anime** — anime uses AniDB sequential numbering with no seasons (Type 2 null). Episode number (1-based; within the season on TV, sequential on anime). Episode title. Falls back to a generated label such as `Episode 148` when no real title is on file. Absolute `simkl.com` URL for the episode. ## `metadata{}` — the show library Keyed by `simkl_id` (as a string). One record per distinct title in the file. **Code defensively — TV records can be sparse.** Newly-added shows that Simkl hasn't finished enriching yet carry only a minimal set of keys (`title`, `url`, `poster`, `ids`, `release_date`, `rank`, `ratings`) and omit the rest. Anime and movie records are consistently complete, but on TV always use optional access (`show.genres ?? []`, `show?.network`) rather than assuming a key exists. Display title. **Anime files only.** Romanized title (e.g. `Chibi Maruko-chan`). Type 4 null when none is on file. Link to the title on simkl.com. Normally a **root-relative path** (`/tv/3437/king-of-the-hill`) — prefix with `https://simkl.com` to build a full link. Sparse TV records may instead carry a fully-qualified URL, so normalize before use rather than blindly concatenating. Localized and alternate titles. Each entry is `name` (string), `lang` (integer — Simkl internal language code, **not** ISO 639), and `type` (one of `official`, `short`, `synonym`, `original`). Image path fragment. Combine with the prefixes in [Image conventions](/conventions/images) — for example `https://wsrv.nl/?url=https://simkl.in/posters/{poster}_m.webp&q=90`. Type 4 null when no poster is on file; fall back to `https://simkl.in/poster_no_pic.png` (see [fallbacks](/conventions/images#fallback-when-images-are-missing)). Backdrop image path fragment. Same prefixing rules as `poster`. Type 4 null when none is on file — commonly absent on new or regional titles. External and Simkl IDs. Always carries `simkl_id` + `slug`. `tmdb` is near-universal; `imdb` / `tvdb` appear on TV and movies, `mal` / `anidb` / `anilist` / `kitsu` on anime. Additional slug variants (`letterslug`, `traktmslug`, `tvdbslug`, `trakttvslug`, `mdlslug`, `jwtv`, …) appear on titles with those platform links — the object is permissive, so read the keys you need and ignore the rest. The title's original premiere / release date. Normally a full ISO 8601 UTC timestamp (`1997-01-12T15:30:00Z`); a small number of TV records carry a date-only string (`2026-07-13`). Parse defensively. Type 4 null when unknown. Note this is the **title's** first-ever release — the per-episode air date is `calendar[].date`. Simkl popularity rank. Lower non-zero values = more popular. `0` or Type 4 `null` for titles that aren't ranked yet (common on new, upcoming, and regional entries — the majority of movie records are unranked). Percentage of users who started the title and abandoned it, as a display string (`"8.5%"`). `"-"` when there's not enough data. Parse with a `parseFloat`-style call if you need it numerically. Number of Simkl users who have watched the title. Type 4 null on unreleased titles. Number of Simkl users with the title on their watchlist. Aggregate ratings keyed by source. Simkl is always present; the second source is `imdb` on TV and movies, `mal` on anime. Individual `rating` / `votes` values are Type 4 null on titles with no votes yet. Simkl community rating. IMDb rating (TV / movies). MyAnimeList rating (anime). Lowercase two-letter country code (`us`, `jp`, `kr`). Lowercase two-letter language code (`en`, `ja`). Episode or feature runtime as a display string (`"22m"`, `"1h 57m"`). Type 4 null when unknown. Release / airing status. Observed values: `ongoing`, `ended`, `premiere`, `soon`, and `undefined` (the literal string, on a handful of un-enriched TV records). Treat this as an open vocabulary — match the values you care about and fall through gracefully on anything else. **TV and anime only.** Total episodes on file across all seasons. **Anime files only.** Catalog format — one of `tv`, `movie`, `ova`, `ona`, `special`, `music`. **TV and anime only.** Broadcasting network or streaming service (`Hulu`, `CBS`). Type 4 null when not on file. **Movie files only.** Physical / digital release date in **`MM/DD/YYYY`** format — note this differs from the ISO 8601 `release_date`. Genre names as display strings (`["Animation", "Comedy"]`). YouTube video ID (not a full URL) — build the link as `https://www.youtube.com/watch?v={trailer}`. Type 4 null when no trailer is on file, which is the majority of entries. ## Worked example — render the next 7 days ```js JavaScript theme={"theme":{"light":"github-light","dark":"vesper"}} const PARAMS = 'client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0'; const res = await fetch(`https://data.simkl.in/calendar/v2/tv.json?${PARAMS}`, { headers: { 'User-Agent': 'my-app-name/1.0' }, // Node only; browsers set this themselves }); const { calendar, metadata } = await res.json(); const now = Date.now(); const weekOut = now + 7 * 24 * 60 * 60 * 1000; const upcoming = calendar .filter(e => { const t = Date.parse(e.date); // date is always UTC 'Z' return t >= now && t <= weekOut; }) .map(e => { const show = metadata[e.simkl_id]; // join on simkl_id return { airsAt: new Date(e.date), title: show.title, poster: show.poster ? `https://wsrv.nl/?url=https://simkl.in/posters/${show.poster}_m.webp&q=90` : 'https://simkl.in/poster_no_pic.png', link: show.url.startsWith('http') ? show.url : `https://simkl.com${show.url}`, episode: `S${e.episode.season}E${e.episode.episode} — ${e.episode.title}`, badge: { 1: 'Mid-Season Finale', 2: 'Season Finale', 3: 'Series Finale' }[e.finale_type] ?? null, genres: show.genres ?? [], // sparse TV records may omit this }; }); ``` ```python Python theme={"theme":{"light":"github-light","dark":"vesper"}} import requests from datetime import datetime, timedelta, timezone PARAMS = { 'client_id': 'YOUR_CLIENT_ID', 'app-name': 'my-app-name', 'app-version': '1.0', } FINALE = {1: 'Mid-Season Finale', 2: 'Season Finale', 3: 'Series Finale'} r = requests.get( 'https://data.simkl.in/calendar/v2/tv.json', params=PARAMS, headers={'User-Agent': 'my-app-name/1.0'}, timeout=15, ) data = r.json() calendar, metadata = data['calendar'], data['metadata'] now = datetime.now(timezone.utc) week_out = now + timedelta(days=7) upcoming = [] for e in calendar: airs = datetime.fromisoformat(e['date'].replace('Z', '+00:00')) if not (now <= airs <= week_out): continue show = metadata[str(e['simkl_id'])] # keys are strings poster = show.get('poster') url = show['url'] upcoming.append({ 'airs_at': airs, 'title': show['title'], 'poster': f"https://wsrv.nl/?url=https://simkl.in/posters/{poster}_m.webp&q=90" if poster else 'https://simkl.in/poster_no_pic.png', 'link': url if url.startswith('http') else f"https://simkl.com{url}", 'episode': f"S{e['episode']['season']}E{e['episode']['episode']} — {e['episode']['title']}", 'badge': FINALE.get(e['finale_type']), 'genres': show.get('genres', []), # sparse TV records may omit this }) ``` **Anime differs in two places.** `calendar[].episode` has no `season` key (use `episode.episode` alone), and `metadata` adds `title_romaji` and `anime_type`. Movie files have no `episode` object at all — the `calendar` entry is just `simkl_id`, `date`, and `finale_type`. # 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-badgateway-oauth2-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-golang-x-oauth2) | ✅ Works as-is | | PHP | [league/oauth2-client](#php-league-oauth2-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. Simkl user dashboard showing 'NOW WATCHING' Fallout S02E07 'The Handoff' resumed at 46%, 27 minutes left ## 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 Simkl user dashboard showing 'NOW WATCHING' Fallout S02E07 'The Handoff' with a 46% watched progress bar and 27 minutes left | 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** 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** 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. **Simkl Pro / VIP only.** Free-tier callers with `?allow_rewatch=yes` get a silent no-op (`added: { movies: 0, shows: 0, episodes: 0 }`) that still consumes a rate-limit slot. Check `account.type` from [`POST /users/settings`](/api-reference/simkl/get-user-settings) at sign-in and gate the flag on `"pro"` / `"vip"`. Cache the value; refetch only when `activities.settings.all` from [`GET /sync/activities`](/api-reference/simkl/get-activities) bumps. Full pattern in [Rewatches guide → Pro / VIP gate](/guides/rewatches). 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.status`** is the **resolved Watchlist status** the server placed the item on — e.g. a `"completed"` write on a still-airing show is silently downgraded to `"watching"` and reflected here. **You don't need a follow-up [`POST /sync/add-to-list`](/api-reference/simkl/add-to-list)** — this call already moves the item; chaining would just overwrite the server's smarter decision. **`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`) only when your app has explicit "Add to Plan to Watch" / "Move to Hold" UI buttons — i.e. 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). **Don't chain `/sync/history` then `/sync/add-to-list` — the history call already moves the item.** `POST /sync/history` automatically places (and re-classifies) the item on the right Watchlist based on the watch event. Read `added.statuses[].response.status` in the history response to see the resolved status — e.g. a `"completed"` write on a still-airing show is silently downgraded to `"watching"` and reflected there. A follow-up `/sync/add-to-list` call is redundant and may overwrite the server's smarter decision. 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) 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) up to per-episode breakdowns. `=full` is **required** for `seasons[].episodes[]` and is the only value that adds `runtime`; it does **not** add `overview` / `fanart` / `genres` / `ratings` (use the detail endpoints for catalog metadata). Pair `=full` and `=full_anime_seasons` with `date_from` — they're significantly larger payloads. | | `include_all_episodes=yes` / `=original` | **Requires `extended=full`** (no effect on its own). Loads `seasons[].episodes[]` for items in `completed` and `dropped` too (which skip episode load by default). `yes` fills in virtual episode rows — stamped with the show's last-watched time — where per-episode data is missing; `original` returns only the episodes the user actually recorded, which can be fewer than `watched_episodes_count` for a show marked complete in one action. | | `episode_watched_at=yes` | Adds per-episode `watched_at` timestamps to every loaded episode. **Requires `extended=full`** — episodes must be loaded first, so on its own it does nothing. | | `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=movies` | Restrict the `anime` results to the anime-movie subtype. `movies` is the only accepted value — it's a subtype filter, not a full type selector. (The per-item `anime_type` **response** field still carries the full vocabulary: `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** — gate the flag on `account.type` from [`POST /users/settings`](/api-reference/simkl/get-user-settings) (cache it; refetch only when `activities.settings.all` bumps — see [Rewatches guide → Pro / VIP gate](/guides/rewatches)). Free-tier callers get a silent no-op that still consumes a rate-limit slot. See the [Rewatches guide](/guides/rewatches) for the full pattern. | **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://wsrv.nl/?url=https://simkl.in/episodes/...&q=90", "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](/conventions/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](/conventions/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](/conventions/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](/conventions/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](/conventions/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). 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 **Resolving more than ~20 IDs in a loop? Stop — you're doing it wrong.** A user's synced watchlist already carries every external ID (IMDB, TMDB, TVDB, MAL, and more) for every item they track. You do **not** need Simkl IDs to mark something watched, add it to a list, or scrobble it — send the external IDs you already have and Simkl matches them for you. If you believe you genuinely need to batch-resolve IDs, [contact us on Discord](https://discord.gg/MJsWNE4) **first**. There's almost always a simpler path, and looping `/search/id` will hit rate limits fast. > ## ⚠️ 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** 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](/conventions/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://wsrv.nl/?url=https://simkl.in/posters/{poster}_m.webp&q=90`). | | `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 | **Regeneration is best-effort, not strictly precise.** The cadences above are targets — the actual job can take a few extra minutes (and occasionally longer) to finish. If your scheduled refresh hits a `304 Not Modified` right at the expected time, the new file just hasn't been generated yet; back off and retry in a few minutes rather than waiting another full cycle. See [SimklTrendingClient — drop-in SDK](#simkltrendingclient-sdk) for the recommended pattern. **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. ## All trending data files Append `?client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0` to every URL below — the [`SimklTrendingClient`](#simkltrendingclient-sdk) does this for you from CONFIG; if you're rolling your own, see [Without the SDK](#without-the-sdk). ### Combined Movies + TV + anime in a **single response**. Top-level keys: `movies`, `tv`, `anime`. Best when you want all categories at once and don't want three round-trips. | 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) | | This 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) | | This 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) | ### By type — Movies, TV, Anime One category per file. Top-level response is the array directly (no wrapper object). Best when you only render one category, or want to fetch them independently. | Category | Timeframe | Top 100 | Top 500 | | ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Movies** | 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) | | Movies | This 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) | | Movies | This 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) | | **TV** | 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) | | TV | This 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) | | TV | This 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) | | **Anime** | 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) | | Anime | This 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) | | Anime | This 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) | ### DVD releases (movies only) Latest popular DVD releases — mirrors [Simkl DVD Releases](https://simkl.com/movies/dvd-releases/). No timeframe split; refreshes 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) | ### Copy-paste JSON Drop this object into your app as a constant. **Replace `YOUR_CLIENT_ID` with your [registered app's `client_id`](https://simkl.com/settings/developer/) and `my-app-name` / `1.0` with your app's name and version** — both the URL params (`required_query`) AND the matching `User-Agent` header (`user_agent_header`) are [required on every request](/conventions/headers#required-url-parameters), free or paid plan. Each entry has a `title` (attribution-compliant display label), a `url` (bare path — append `?` + `required_query` when you fetch), and a `refresh_seconds` (how often the file is regenerated server-side — set your refresh timer to this value). The structure is `category → timeframe → size` so you can filter or iterate by any axis. DVD has no timeframe. ```json theme={"theme":{"light":"github-light","dark":"vesper"}} { "required_query": "client_id=YOUR_CLIENT_ID&app-name=my-app-name&app-version=1.0", "user_agent_header": "my-app-name/1.0", "urls": { "combined": { "today": { "top_100": { "title": "Trending on Simkl — Today (Top 100)", "url": "https://data.simkl.in/discover/trending/today_100.json", "refresh_seconds": 3600 }, "top_500": { "title": "Trending on Simkl — Today (Top 500)", "url": "https://data.simkl.in/discover/trending/today_500.json", "refresh_seconds": 3600 } }, "week": { "top_100": { "title": "Trending on Simkl — This Week (Top 100)", "url": "https://data.simkl.in/discover/trending/week_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending on Simkl — This Week (Top 500)", "url": "https://data.simkl.in/discover/trending/week_500.json", "refresh_seconds": 86400 } }, "month": { "top_100": { "title": "Trending on Simkl — This Month (Top 100)", "url": "https://data.simkl.in/discover/trending/month_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending on Simkl — This Month (Top 500)", "url": "https://data.simkl.in/discover/trending/month_500.json", "refresh_seconds": 86400 } } }, "movies": { "today": { "top_100": { "title": "Trending Movies on Simkl — Today (Top 100)", "url": "https://data.simkl.in/discover/trending/movies/today_100.json", "refresh_seconds": 3600 }, "top_500": { "title": "Trending Movies on Simkl — Today (Top 500)", "url": "https://data.simkl.in/discover/trending/movies/today_500.json", "refresh_seconds": 3600 } }, "week": { "top_100": { "title": "Trending Movies on Simkl — This Week (Top 100)", "url": "https://data.simkl.in/discover/trending/movies/week_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending Movies on Simkl — This Week (Top 500)", "url": "https://data.simkl.in/discover/trending/movies/week_500.json", "refresh_seconds": 86400 } }, "month": { "top_100": { "title": "Trending Movies on Simkl — This Month (Top 100)", "url": "https://data.simkl.in/discover/trending/movies/month_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending Movies on Simkl — This Month (Top 500)", "url": "https://data.simkl.in/discover/trending/movies/month_500.json", "refresh_seconds": 86400 } } }, "tv": { "today": { "top_100": { "title": "Trending TV Shows on Simkl — Today (Top 100)", "url": "https://data.simkl.in/discover/trending/tv/today_100.json", "refresh_seconds": 3600 }, "top_500": { "title": "Trending TV Shows on Simkl — Today (Top 500)", "url": "https://data.simkl.in/discover/trending/tv/today_500.json", "refresh_seconds": 3600 } }, "week": { "top_100": { "title": "Trending TV Shows on Simkl — This Week (Top 100)", "url": "https://data.simkl.in/discover/trending/tv/week_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending TV Shows on Simkl — This Week (Top 500)", "url": "https://data.simkl.in/discover/trending/tv/week_500.json", "refresh_seconds": 86400 } }, "month": { "top_100": { "title": "Trending TV Shows on Simkl — This Month (Top 100)", "url": "https://data.simkl.in/discover/trending/tv/month_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending TV Shows on Simkl — This Month (Top 500)", "url": "https://data.simkl.in/discover/trending/tv/month_500.json", "refresh_seconds": 86400 } } }, "anime": { "today": { "top_100": { "title": "Trending Anime on Simkl — Today (Top 100)", "url": "https://data.simkl.in/discover/trending/anime/today_100.json", "refresh_seconds": 3600 }, "top_500": { "title": "Trending Anime on Simkl — Today (Top 500)", "url": "https://data.simkl.in/discover/trending/anime/today_500.json", "refresh_seconds": 3600 } }, "week": { "top_100": { "title": "Trending Anime on Simkl — This Week (Top 100)", "url": "https://data.simkl.in/discover/trending/anime/week_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending Anime on Simkl — This Week (Top 500)", "url": "https://data.simkl.in/discover/trending/anime/week_500.json", "refresh_seconds": 86400 } }, "month": { "top_100": { "title": "Trending Anime on Simkl — This Month (Top 100)", "url": "https://data.simkl.in/discover/trending/anime/month_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Trending Anime on Simkl — This Month (Top 500)", "url": "https://data.simkl.in/discover/trending/anime/month_500.json", "refresh_seconds": 86400 } } }, "dvd": { "top_100": { "title": "Latest DVD Releases on Simkl (Top 100)", "url": "https://data.simkl.in/discover/dvd/releases_100.json", "refresh_seconds": 86400 }, "top_500": { "title": "Latest DVD Releases on Simkl (Top 500)", "url": "https://data.simkl.in/discover/dvd/releases_500.json", "refresh_seconds": 86400 } } }, "catalogs": [ { "id": "movies_today", "source": "movies", "timeframe": "today" }, { "id": "tv_today", "source": "tv", "timeframe": "today" }, { "id": "anime_today", "source": "anime", "timeframe": "today" }, { "id": "dvd", "source": "dvd" }, { "id": "tv_premieres", "source": "tv", "title": "Trending TV Premieres on Simkl (Last 30 Days)", "recipe": "justReleased", "recipe_opts": { "withinDays": 30 } }, { "id": "movies_premieres", "source": "movies", "title": "Trending Movie Premieres on Simkl (Last 30 Days)", "recipe": "justReleased", "recipe_opts": { "withinDays": 30 } }, { "id": "tv_ongoing", "source": "tv", "title": "Currently Airing TV on Simkl", "recipe": "ongoing" }, { "id": "movies_this_year", "source": "movies", "title": "Movies This Year on Simkl", "recipe": "currentYear" }, { "id": "tv_this_year", "source": "tv", "title": "TV This Year on Simkl", "recipe": "currentYear" }, { "id": "anime_this_year", "source": "anime", "title": "Anime This Year on Simkl", "recipe": "currentYear" }, { "id": "movies_last_year", "source": "movies", "title": "Movies of Last Year on Simkl", "recipe": "lastYear" }, { "id": "tv_last_year", "source": "tv", "title": "TV of Last Year on Simkl", "recipe": "lastYear" }, { "id": "movies_anticipated", "source": "movies", "title": "Most Watchlisted Movies on Simkl", "recipe": "mostWatchlisted" }, { "id": "tv_anticipated", "source": "tv", "title": "Most Watchlisted TV on Simkl", "recipe": "mostWatchlisted" }, { "id": "anime_anticipated", "source": "anime", "title": "Most Watchlisted Anime on Simkl", "recipe": "mostWatchlisted" }, { "id": "movies_top_rated", "source": "movies", "title": "Top Rated Movies on Simkl", "recipe": "highestRated" }, { "id": "tv_top_rated", "source": "tv", "title": "Top Rated TV on Simkl", "recipe": "highestRated" }, { "id": "anime_top_rated", "source": "anime", "title": "Top Rated Anime on Simkl", "recipe": "highestRated", "recipe_opts": { "source": "mal" } }, { "id": "movies_by_rank", "source": "movies", "title": "All-Time Best Movies on Simkl", "recipe": "byRank" }, { "id": "tv_by_rank", "source": "tv", "title": "All-Time Best TV on Simkl", "recipe": "byRank" }, { "id": "anime_by_rank", "source": "anime", "title": "All-Time Best Anime on Simkl", "recipe": "byRank" }, { "id": "movies_box_office", "source": "movies", "title": "Top Box Office Movies on Simkl", "recipe": "bestBoxOffice" }, { "id": "movies_hidden_gems", "source": "movies", "title": "Hidden Gem Movies on Simkl", "recipe": "hiddenGems" }, { "id": "tv_hidden_gems", "source": "tv", "title": "Hidden Gem TV on Simkl", "recipe": "hiddenGems" }, { "id": "anime_hidden_gems", "source": "anime", "title": "Hidden Gem Anime on Simkl", "recipe": "hiddenGems", "recipe_opts": { "source": "mal" } }, { "id": "tv_marathon", "source": "tv", "title": "Marathon-Worthy TV on Simkl", "recipe": "marathonWorthy" }, { "id": "movies_quick", "source": "movies", "title": "Quick Watches on Simkl (≤ 90 min)", "recipe": "quickWatches" }, { "id": "tv_best_netflix", "source": "tv", "title": "Best of Netflix on Simkl", "recipe": "bestOfNetwork", "recipe_opts": { "network": "Netflix" } }, { "id": "tv_best_hbo", "source": "tv", "title": "Best of HBO on Simkl", "recipe": "bestOfNetwork", "recipe_opts": { "network": "HBO" } }, { "id": "tv_best_disney", "source": "tv", "title": "Best of Disney+ on Simkl", "recipe": "bestOfNetwork", "recipe_opts": { "network": "Disney+" } }, { "id": "tv_best_prime", "source": "tv", "title": "Best of Prime Video on Simkl", "recipe": "bestOfNetwork", "recipe_opts": { "network": "Prime Video" } }, { "id": "tv_best_apple", "source": "tv", "title": "Best of Apple TV on Simkl", "recipe": "bestOfNetwork", "recipe_opts": { "network": "Apple TV" } }, { "id": "movies_best_action", "source": "movies", "title": "Best Action Movies on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Action" } }, { "id": "movies_best_drama", "source": "movies", "title": "Best Drama Movies on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Drama" } }, { "id": "movies_best_comedy", "source": "movies", "title": "Best Comedy Movies on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Comedy" } }, { "id": "movies_best_scifi", "source": "movies", "title": "Best Sci-Fi Movies on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Science Fiction" } }, { "id": "movies_best_thriller", "source": "movies", "title": "Best Thriller Movies on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Thriller" } }, { "id": "movies_best_horror", "source": "movies", "title": "Best Horror Movies on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Horror" } }, { "id": "tv_best_drama", "source": "tv", "title": "Best Drama TV on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Drama" } }, { "id": "tv_best_comedy", "source": "tv", "title": "Best Comedy TV on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Comedy" } }, { "id": "tv_best_crime", "source": "tv", "title": "Best Crime TV on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Crime" } }, { "id": "tv_best_scifi", "source": "tv", "title": "Best Sci-Fi TV on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Science-Fiction" } }, { "id": "anime_best_shounen", "source": "anime", "title": "Best Shounen Anime on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Shounen", "source": "mal" } }, { "id": "anime_best_isekai", "source": "anime", "title": "Best Isekai Anime on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Isekai", "source": "mal" } }, { "id": "anime_best_romance", "source": "anime", "title": "Best Romance Anime on Simkl", "recipe": "bestOfGenre", "recipe_opts": { "genre": "Romance", "source": "mal" } }, { "id": "movies_best_2020s", "source": "movies", "title": "Best Movies of the 2020s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 2020 } }, { "id": "movies_best_2010s", "source": "movies", "title": "Best Movies of the 2010s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 2010 } }, { "id": "movies_best_2000s", "source": "movies", "title": "Best Movies of the 2000s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 2000 } }, { "id": "movies_best_90s", "source": "movies", "title": "Best Movies of the 90s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 1990 } }, { "id": "tv_best_2020s", "source": "tv", "title": "Best TV of the 2020s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 2020 } }, { "id": "tv_best_2010s", "source": "tv", "title": "Best TV of the 2010s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 2010 } }, { "id": "anime_best_2020s", "source": "anime", "title": "Best Anime of the 2020s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 2020, "source": "mal" } }, { "id": "anime_best_2010s", "source": "anime", "title": "Best Anime of the 2010s on Simkl", "recipe": "bestOfDecade", "recipe_opts": { "decade": 2010, "source": "mal" } }, { "id": "anime_movies", "source": "anime", "title": "Trending Anime Movies on Simkl", "recipe": "byAnimeType", "recipe_opts": { "animeType": "movie" } }, { "id": "anime_ovas", "source": "anime", "title": "Trending Anime OVAs on Simkl", "recipe": "byAnimeType", "recipe_opts": { "animeType": "ova" } } ] } ``` * **Titles** are pre-composed to satisfy the [attribution requirement](#attribution-required) (every label includes both *"Trending"* / *"Most Watched"* and *"Simkl"*). Use them as-is or adapt the wording — just keep both halves. * **`catalogs`** is the default row list the SDK renders on a home screen — **44 rows out of the box**, all titled and ready to display. Mix of: trending (movies/tv/anime today + DVD), sort variants (`byRank`, `highestRated`, `mostAnticipated` × 3 types), composed catalogs (`hiddenGems` × 3, `criticallyAcclaimed` × 2, `marathonWorthy`, `bestBoxOffice`), **Best of ** (Netflix / HBO / Disney+ / Prime Video / Apple TV), **Best of ** for movies (Action / Sci-Fi / Comedy / Horror / Thriller / Drama), TV (Drama / Comedy / Crime / Sci-Fi), anime (Shounen / Isekai / Romance), and time-based filters (Quick Watches, In Theaters Now, Just Released on DVD, Currently Airing TV, Anime Movies, Anime OVAs). Edit the JSON to remove rows you don't want, change titles, or swap recipe opts. Per-app override still works via `cfg.catalogs` in the SDK config. Currently the recipes ship in the Node + Browser tabs below; Python / Swift / Kotlin / Dart will render the derived rows as raw items until those tabs gain recipe ports (or you supply a `transform` closure). * **`refresh_seconds`** is `3600` (1 hour) for `today` files and `86400` (1 day) for everything else, matching the [Update frequency](#update-frequency) table. **This is the nominal regeneration cadence, not a guarantee** — server regeneration can take a few minutes longer. For bandwidth-optimal refresh, pair this with the response's `Last-Modified` header and send `If-Modified-Since` on subsequent requests — the server returns `304 Not Modified` when nothing has changed. If a refresh scheduled for `refresh_seconds` returns 304, back off and retry rather than assuming the cache is final (see [SimklTrendingClient — drop-in SDK](#simkltrendingclient-sdk)). **Using the SDK below?** Save this whole object as **`simkl-trending-urls.json`** in your project — the SDK class loads it back into a `SIMKL_TRENDING_URLS` symbol. Each language tab's top comment shows the runtime-appropriate one-liner. ## SimklTrendingClient — drop-in SDK **Two-step setup before pasting the SDK class below.** First, save the [Copy-paste JSON](#copy-paste-json) block above as **`simkl-trending-urls.json`** in your project (or as an asset your runtime can load). Second, load it back into a `SIMKL_TRENDING_URLS` symbol — each tab's top comment shows the runtime-appropriate one-liner (`import` / `require` / `fetch` for JS, `open()` for Python, `Bundle.main` for Swift, `context.assets` for Android, `rootBundle` for Flutter). The constructor throws if neither this symbol nor an explicit `urls` argument is in scope. A single class that wires up the entire data flow. Tune the `CONFIG` block at the top of each tab — everything else is plumbing you should not need to edit. What's built in: * **Auto-picks the [Combined](#combined) endpoint** when you ask for 2+ of movies/tv/anime — one HTTP call per timeframe instead of two or three. * **Persistent cache** keyed on the URL — relaunch can paint the UI instantly from the last persisted snapshot before any network call fires. JS auto-detects the host (browser → `localStorage[storageKey]`, Node → file at `persistPath`); other languages use platform-native paths. Cache entries older than `persistMaxAgeSeconds` are dropped on restore. `client.clearCache()` wipes both in-memory and persistent state (and notifies subscribers so the UI repaints empty). For server-side runtimes with many processes, swap the `_persist` / `_restorePersisted` / `clearCache` trio for a Redis / KV adapter. * **Conditional revalidation** with `If-Modified-Since` so refreshes hit the CDN with a cheap 304 when nothing has changed. * **Regen-lag backoff** — when the server hasn't regenerated by the time `refresh_seconds` elapses, the client backs off via a `[5min, 15min, 30min]` ladder instead of hammering on the nominal cadence. * **Jitter** — ±5% random jitter on every refresh + ±10% on retry delays, so all clients don't hit the CDN at exactly `:00` and don't retry-storm in lockstep. * **Retry with exponential backoff + per-request timeout + `Retry-After` honored** for network blips and `429`s. * **Permanent-error classification** — `404` / `400` / `401` / `403` are not retried (they'd waste the backoff schedule on a request that will never succeed); the entry's loop halts and you see the error in the log. * **Process-wide `minIntervalMs` floor** — N entries refreshing at the same tick are serialized through a 250 ms (configurable) gate so you never burst N parallel requests at the CDN. * **Concurrent fetch dedup** — two callers asking for the same URL fold into one in-flight request. * **Cancellation** — `client.stop()` aborts every in-flight request and clears every timer. * **Subscribe / get** — your UI subscribes to a type and receives the current cached items immediately, plus a callback on every successful refresh. `get(type)` is synchronous — paginate the returned array locally for UI pages instead of refetching. * **`client.tick()`** for background schedulers — call from a `BGTaskScheduler` / `WorkManager` / `workmanager` / cron periodic task to perform a single revalidate-and-persist pass. * **Namespace-safe** — every public symbol is prefixed `Simkl…` (`SimklTrendingClient`, `SimklTrendingConfig`, `SimklTrendingError`, `SIMKL_TRENDING_URLS`, etc.) so dropping the class into an existing app doesn't collide with your own `Config` / `Client` / `Entry` types. The only thing left to wire up per-platform is **background scheduling** (the OS-level periodic task that keeps the cache warm while your app is suspended) — see the comment at the bottom of each tab. ### Try it live A self-contained vanilla-JS demo runs in CodePen below — same SDK, all 55 default catalog rows, two live fetches against `data.simkl.in`: [`trending/today_500.json`](https://data.simkl.in/discover/trending/today_500.json) (combined movies/TV/anime in one round-trip) and [`dvd/releases_500.json`](https://data.simkl.in/discover/dvd/releases_500.json). Click **Result** to see the rendered home screen; **JS** / **HTML** / **CSS** tabs show the wiring.